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

[tag]0.1-vcci

parents dacb81a5 d0f45809
Pipeline #53389 passed with stages
in 6 minutes and 14 seconds
...@@ -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,12 +369,11 @@ export function AdvertisementList({ ...@@ -369,12 +369,11 @@ 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
className="object-cover" className="object-cover"
unoptimized={isGif}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
...@@ -514,12 +513,11 @@ export function AdvertisementList({ ...@@ -514,12 +513,11 @@ 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
className="object-cover" className="object-cover"
unoptimized={form.filePreviewUrl.toLowerCase().endsWith(".gif")}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
......
"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",
};
}
This diff is collapsed.
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;
}
This diff is collapsed.
...@@ -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,
};
}
This diff is collapsed.
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.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Không có data mạng xã hội
</div>
) : (
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,
};
}
This diff is collapsed.
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
...@@ -24,12 +23,15 @@ import { ...@@ -24,12 +23,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useQueryClient } from "@tanstack/react-query";
import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
import { import {
CONTACT_PURPOSE_OPTIONS, CONTACT_PURPOSE_OPTIONS,
type ContactRequestItem, type AdminContactRequestRow,
persistContactRequests, extractRows,
readContactRequests, mapApiRowToAdminContactRequest,
} from "@/mockdata/contact-management"; } from "@/lib/utils/admin-contact";
import { useMemo, useState } from "react";
const selectTriggerClassName = const selectTriggerClassName =
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[220px]"; "w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[220px]";
...@@ -38,23 +40,35 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700"; ...@@ -38,23 +40,35 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"; const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function formatDateTime(value: string) { function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm"); return dayjs(value).format("DD/MM/YYYY HH:mm");
} }
export default function AdminContactRequestsPage() { export default function AdminContactRequestsPage() {
const [items, setItems] = React.useState<ContactRequestItem[]>([]); const queryClient = useQueryClient();
const [search, setSearch] = React.useState("");
const [purposeFilter, setPurposeFilter] = React.useState("all");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<ContactRequestItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ContactRequestItem | null>(null);
React.useEffect(() => { const [search, setSearch] = useState("");
setItems(readContactRequests()); const [purposeFilter, setPurposeFilter] = useState("all");
setReady(true); const [detailTarget, setDetailTarget] = useState<AdminContactRequestRow | null>(null);
}, []); const [deleteTarget, setDeleteTarget] = useState<AdminContactRequestRow | null>(null);
const filteredItems = React.useMemo(() => { const contactsQuery = useGetApiV10Contact({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const deleteContact = useDeleteApiV10ContactId();
const items = useMemo(
() =>
extractRows((contactsQuery.data as any)?.responseData).map(
mapApiRowToAdminContactRequest,
),
[contactsQuery.data],
);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return items.filter((item) => { return items.filter((item) => {
...@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() { ...@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
}); });
}, [items, purposeFilter, search]); }, [items, purposeFilter, search]);
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(nextItems); await deleteContact.mutateAsync({ id: deleteTarget.id });
persistContactRequests(nextItems);
toast.success("Đã xóa đơn liên hệ"); toast.success("Đã xóa đơn liên hệ");
queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Không thể xóa đơn liên hệ";
toast.error(message);
} finally {
setDeleteTarget(null); setDeleteTarget(null);
}
}; };
return ( return (
...@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() { ...@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {contactsQuery.isLoading ? (
Array.from({ length: 4 }).map((_, index) => ( Array.from({ length: 4 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4"> <TableCell colSpan={7} className="px-4 py-4">
...@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() { ...@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() {
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.purpose} {item.purpose || "—"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-800"> <TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{item.contactName}</div> <div className="font-semibold">{item.contactName || "—"}</div>
<div className="text-gray-600">{item.contactPhone}</div> <div className="text-gray-600">{item.contactPhone || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-gray-800">{item.organizationName}</div> <div className="font-medium text-gray-800">{item.organizationName || "—"}</div>
<div>{item.businessField}</div> <div>{item.businessField || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div>{item.email}</div> <div>{item.email || "—"}</div>
<div className="text-gray-500">{item.contactEmail}</div> <div className="text-gray-500">{item.contactEmail || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700"> <TableCell className="py-3 text-center text-sm text-gray-700">
...@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() { ...@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() {
badge={ badge={
detailTarget ? ( detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.purpose} {detailTarget.purpose || "—"}
</Badge> </Badge>
) : null ) : null
} }
...@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() { ...@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() {
{ {
title: "Thông tin chung", title: "Thông tin chung",
fields: [ fields: [
{ label: "Mục đích liên hệ", value: detailTarget.purpose }, { label: "Mục đích liên hệ", value: detailTarget.purpose || "—" },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) }, { label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
], ],
}, },
{ {
title: "Người liên hệ", title: "Người liên hệ",
fields: [ fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName }, { label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition }, { label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail }, { label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone }, { label: "Điện thoại người liên hệ", value: detailTarget.contactPhone || "—" },
{ label: "Nội dung liên hệ", value: detailTarget.message, fullWidth: true }, { label: "Nội dung liên hệ", value: detailTarget.message || "—", fullWidth: true },
], ],
}, },
{ {
title: "Thông tin công ty / tổ chức", title: "Thông tin công ty / tổ chức",
fields: [ fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName }, { label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField }, { label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Email", value: detailTarget.email }, { label: "Email", value: detailTarget.email || "—" },
{ label: "Website", value: detailTarget.website }, { label: "Website", value: detailTarget.website || "—" },
], ],
}, },
] ]
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
...@@ -17,29 +16,72 @@ import { ...@@ -17,29 +16,72 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { useQueryClient } from "@tanstack/react-query";
type MembershipApplicationItem, import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
persistMembershipApplications, import { extractRows } from "@/lib/utils/admin-contact";
readMembershipApplications, import { useMemo, useState } from "react";
} from "@/mockdata/contact-management";
interface AdminMembershipApplicationRow {
id: string;
organizationName: string;
businessField: string;
address: string;
website: string;
contactName: string;
contactPosition: string;
contactEmail: string;
contactPhone: string;
membershipType: string;
note: string;
submittedAt: string;
}
function mapApiRowToMembershipApplication(item: any): AdminMembershipApplicationRow {
return {
id: String(item?.id ?? ""),
organizationName: item?.fullname ?? "",
businessField: "",
address: "",
website: "",
contactName: item?.fullname ?? "",
contactPosition: "",
contactEmail: item?.email ?? "",
contactPhone: item?.phone ?? "",
membershipType: item?.title ?? "",
note: item?.content ?? "",
submittedAt: item?.created_at ?? "",
};
}
function formatDateTime(value: string) { function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm"); return dayjs(value).format("DD/MM/YYYY HH:mm");
} }
export default function AdminMembershipApplicationsPage() { export default function AdminMembershipApplicationsPage() {
const [items, setItems] = React.useState<MembershipApplicationItem[]>([]); const queryClient = useQueryClient();
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<MembershipApplicationItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MembershipApplicationItem | null>(null);
React.useEffect(() => { const [search, setSearch] = useState("");
setItems(readMembershipApplications()); const [detailTarget, setDetailTarget] = useState<AdminMembershipApplicationRow | null>(null);
setReady(true); const [deleteTarget, setDeleteTarget] = useState<AdminMembershipApplicationRow | null>(null);
}, []);
const filteredItems = React.useMemo(() => { const contactsQuery = useGetApiV10Contact({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const deleteContact = useDeleteApiV10ContactId();
const items = useMemo(
() =>
extractRows((contactsQuery.data as any)?.responseData).map(
mapApiRowToMembershipApplication,
),
[contactsQuery.data],
);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return items; if (!keyword) return items;
...@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() { ...@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
); );
}, [items, search]); }, [items, search]);
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(nextItems); await deleteContact.mutateAsync({ id: deleteTarget.id });
persistMembershipApplications(nextItems);
toast.success("Đã xóa đơn đăng ký hội viên"); toast.success("Đã xóa đơn đăng ký hội viên");
queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Không thể xóa đơn đăng ký hội viên";
toast.error(message);
} finally {
setDeleteTarget(null); setDeleteTarget(null);
}
}; };
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<AdminTableLayout <AdminTableLayout
searchValue={search} searchValue={search}
searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..." searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..."
...@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() { ...@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {contactsQuery.isLoading ? (
Array.from({ length: 3 }).map((_, index) => ( Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4"> <TableCell colSpan={7} className="px-4 py-4">
...@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() { ...@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-800"> <TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{item.organizationName}</div> <div className="font-semibold">{item.organizationName || "—"}</div>
<div className="text-gray-600">{item.businessField}</div> <div className="text-gray-600">{item.businessField || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-gray-800">{item.contactName}</div> <div className="font-medium text-gray-800">{item.contactName || "—"}</div>
<div>{item.contactPhone}</div> <div>{item.contactPhone || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.membershipType} {item.membershipType || "—"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700">{item.contactEmail}</TableCell> <TableCell className="py-3 text-sm text-gray-700">{item.contactEmail || "—"}</TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700"> <TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)} {formatDateTime(item.submittedAt)}
</TableCell> </TableCell>
...@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() { ...@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() {
<ContactManagementDetailDialog <ContactManagementDetailDialog
open={!!detailTarget} open={!!detailTarget}
title="Chi tiết đơn đăng ký hội viên" title="Chi tiết đơn đăng ký hội viên"
description="Biểu mẫu mẫu phục vụ duyệt giao diện quản trị đơn đăng ký hội viên." description="Thông tin đầy đủ của đơn đăng ký hội viên được gửi từ website VCCI News."
badge={ badge={
detailTarget ? ( detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.membershipType} {detailTarget.membershipType || "—"}
</Badge> </Badge>
) : null ) : null
} }
...@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() { ...@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() {
{ {
title: "Thông tin chung", title: "Thông tin chung",
fields: [ fields: [
{ label: "Loại hội viên", value: detailTarget.membershipType }, { label: "Loại hội viên", value: detailTarget.membershipType || "—" },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) }, { label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
], ],
}, },
{ {
title: "Thông tin doanh nghiệp", title: "Thông tin doanh nghiệp",
fields: [ fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName }, { label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField }, { label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Địa chỉ", value: detailTarget.address, fullWidth: true }, { label: "Địa chỉ", value: detailTarget.address || "—", fullWidth: true },
{ label: "Website", value: detailTarget.website }, { label: "Website", value: detailTarget.website || "—" },
], ],
}, },
{ {
title: "Thông tin người liên hệ", title: "Thông tin người liên hệ",
fields: [ fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName }, { label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition }, { label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail }, { label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone }, { label: "Điện thoại người liên hệ", value: detailTarget.contactPhone || "—" },
{ label: "Ghi chú", value: detailTarget.note, fullWidth: true }, { label: "Ghi chú", value: detailTarget.note || "—", fullWidth: true },
], ],
}, },
] ]
......
This diff is collapsed.
...@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions"; ...@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid"; import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Pagination } from "@/components/base/pagination"; import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -26,10 +26,15 @@ import { ...@@ -26,10 +26,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post"; import {
import { deleteApiV10PostId } from "@/api/vcci-news/endpoints/post"; useDeleteApiV10PostId,
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category"; useGetApiV10Post,
import { ADMIN_NEWS_TYPE_LABELS, type AdminNewsItem } from "@/mockdata/admin-news"; } from "@/api/vcci-news/endpoints/post";
import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import {
ADMIN_NEWS_TYPE_LABELS,
type AdminNewsItem,
} from "@/api/vcci-news/types/post";
import { normalizeUser } from "@/lib/utils/cms-user"; import { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime"; import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content"; import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
...@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() { ...@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? ""); const categoryId = String(params.categoryId ?? "");
const [items, setItems] = useState<AdminNewsItem[]>([]); const { data: treeResponse, isLoading: isTreeLoading } = useGetApiV10CategoryTree();
const [headerTree, setHeaderTree] = useState<HeaderCategoryTreeItem[]>([]); const { mutateAsync: deletePost, isPending: isDeletingPost } = useDeleteApiV10PostId();
const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null);
const didMountRef = useRef(false); const didMountRef = useRef(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => { const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1); const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1; return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
}); });
const listQueryString = useMemo(() => { const apiFilters = useMemo(() => {
const nextParams = new URLSearchParams();
if (page > 1) {
nextParams.set("page", String(page));
}
if (search.trim()) {
nextParams.set("q", search.trim());
}
return nextParams.toString();
}, [page, search]);
const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname],
);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const keyword = search.trim(); const keyword = search.trim();
const filters = [ const filters = [
`category.id==${categoryId}`, `category.id==${categoryId}`,
keyword ? `title@=${keyword}|slug@=${keyword}` : "", keyword ? `title@=${keyword}|slug@=${keyword}` : "",
].filter(Boolean).join(","); ].filter(Boolean).join(",");
return filters.trim() || undefined;
}, [categoryId, search]);
const [newsResponse, treeResponse] = await Promise.all([ const postsQuery = useGetApiV10Post({
getApiV10Post({
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
sortField: "created_at", sortField: "created_at",
sortOrder: "desc", sortOrder: "desc",
filters: filters.trim() || undefined, filters: apiFilters,
}), });
getApiV10CategoryTree(),
]);
if (cancelled) return;
const newsResult = (newsResponse.responseData ?? {}) as { rows?: any[]; count?: number }; const postsResponse = (postsQuery.data ?? {}) as { responseData?: { rows?: any[]; count?: number } };
const tree = (treeResponse.responseData ?? []) as unknown as HeaderCategoryTreeItem[]; const items: AdminNewsItem[] = useMemo(
setItems((newsResult.rows ?? []).map((item: any): AdminNewsItem => { () =>
(postsResponse.responseData?.rows ?? []).map((item: any): AdminNewsItem => {
const structuredContent = parsePostContent(item.content_structure); const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content); const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : []; const categories = Array.isArray(item.categories) ? item.categories : [];
...@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() { ...@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() {
creator: normalizeUser(item.creator), creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor), editor: normalizeUser(item.editor),
}; };
})); }),
setTotal(newsResult.count ?? 0); [postsResponse.responseData],
setHeaderTree(tree); );
setReady(true); const total = postsResponse.responseData?.count ?? 0;
} catch (error) {
if (cancelled) return; const headerTree: HeaderCategoryTreeItem[] = useMemo(
toast.error(error instanceof Error ? error.message : "Không thể tải dữ liệu"); () => (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[],
setReady(true); [treeResponse],
);
const ready = !isTreeLoading && !postsQuery.isLoading;
const listQueryString = useMemo(() => {
const nextParams = new URLSearchParams();
if (page > 1) {
nextParams.set("page", String(page));
} }
};
void load(); if (search.trim()) {
nextParams.set("q", search.trim());
}
return () => { return nextParams.toString();
cancelled = true; }, [page, search]);
};
}, [categoryId, page, search]); const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname],
);
const flatCategories = useMemo(() => { const flatCategories = useMemo(() => {
return flattenTree(headerTree); return flattenTree(headerTree);
...@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() { ...@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
}, [items, total]); }, [items, total]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget || isDeletingPost) return;
try { try {
await deleteApiV10PostId(deleteTarget.id); await deletePost({ id: deleteTarget.id });
setItems((current) => current.filter((item) => item.id !== deleteTarget.id));
setTotal((current) => Math.max(0, current - 1));
toast.success("Đã xóa bài viết"); toast.success("Đã xóa bài viết");
setDeleteTarget(null); setDeleteTarget(null);
postsQuery.refetch();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết"); toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết");
} }
...@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() { ...@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
<TableCell className="text-center"> <TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]"> <div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? ( {item.thumbnail ? (
<SafeImage <Image
src={item.thumbnail.url} src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name} alt={item.thumbnail.alt || item.thumbnail.name}
fill fill
......
...@@ -20,11 +20,11 @@ import { ...@@ -20,11 +20,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { toSlug } from "@/lib/utils/header-category";
type HeaderCategoryTreeItem, import type {
type HeaderCategoryType, HeaderCategoryTreeItem,
toSlug, HeaderCategoryType,
} from "@/mockdata/header-config"; } from "@/api/vcci-news/types/header-config";
export type HeaderCategoryFormMode = "create" | "edit"; export type HeaderCategoryFormMode = "create" | "edit";
......
...@@ -20,10 +20,8 @@ import { ...@@ -20,10 +20,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { getHeaderCategoryTypeLabel } from "@/lib/utils/header-category";
type HeaderCategoryTreeItem, import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
getHeaderCategoryTypeLabel,
} from "@/mockdata/header-config";
export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & { export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
depth: number; depth: number;
......
"use client"; "use client";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {
useDeleteApiV10CategoryId,
useGetApiV10CategoryTree,
usePostApiV10Category,
usePutApiV10CategoryId,
} from "@/api/vcci-news/endpoints/category";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
HeaderCategoryDeleteDialog, HeaderCategoryDeleteDialog,
...@@ -12,17 +18,11 @@ import { ...@@ -12,17 +18,11 @@ import {
HeaderCategoryTable, HeaderCategoryTable,
} from "./components"; } from "./components";
import { buildStaticLink } from "@/lib/utils/header-link"; import { buildStaticLink } from "@/lib/utils/header-link";
import { import { toSlug } from "@/lib/utils/header-category";
deleteApiV10CategoryId, import type {
getApiV10CategoryTree,
postApiV10Category,
putApiV10CategoryId,
} from "@/api/vcci-news/endpoints/category";
import {
HeaderCategoryItem, HeaderCategoryItem,
HeaderCategoryTreeItem, HeaderCategoryTreeItem,
toSlug, } from "@/api/vcci-news/types/header-config";
} from "@/mockdata/header-config";
const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = { const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
name: "", name: "",
...@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue ...@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
} }
function useHeaderConfigModule() { function useHeaderConfigModule() {
const {
data: treeResponse,
isLoading,
error,
refetch: refetchTree,
} = useGetApiV10CategoryTree();
const [tree, setTree] = useState<HeaderCategoryTreeItem[]>([]); const [tree, setTree] = useState<HeaderCategoryTreeItem[]>([]);
const [rootStaticLink, setRootStaticLink] = useState("/"); const [rootStaticLink, setRootStaticLink] = useState("/");
const [isReady, setIsReady] = useState(false);
const load = useCallback(async () => {
const response = await getApiV10CategoryTree();
const treeData = (response.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
useEffect(() => {
const treeData = (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setTree(treeData); setTree(treeData);
setRootStaticLink("/"); setRootStaticLink("/");
setIsReady(true); }, [treeResponse]);
}, []);
useEffect(() => { useEffect(() => {
void load().catch((error) => { if (error) {
toast.error(error instanceof Error ? error.message : "Không thể tải cấu hình danh mục"); toast.error(error instanceof Error ? error.message : "Không thể tải cấu hình danh mục");
setIsReady(true); }
}); }, [error]);
}, [load]);
return { return {
tree, tree,
rootStaticLink, rootStaticLink,
isReady, isReady: !isLoading,
reload: load, reload: refetchTree,
}; };
} }
export default function HeaderConfigPage() { export default function HeaderConfigPage() {
const { tree, rootStaticLink, isReady, reload } = useHeaderConfigModule(); const { tree, rootStaticLink, isReady, reload } = useHeaderConfigModule();
const { mutateAsync: createCategory, isPending: isCreatingCategory } = usePostApiV10Category();
const { mutateAsync: updateCategory, isPending: isUpdatingCategory } = usePutApiV10CategoryId();
const { mutateAsync: deleteCategory, isPending: isDeletingCategory } = useDeleteApiV10CategoryId();
const isMutating = isCreatingCategory || isUpdatingCategory || isDeletingCategory;
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -221,7 +226,7 @@ export default function HeaderConfigPage() { ...@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) return; if (isSubmitting || isMutating) return;
if (isProtectedHomeCategory(formValues.id)) { if (isProtectedHomeCategory(formValues.id)) {
return; return;
...@@ -277,10 +282,10 @@ export default function HeaderConfigPage() { ...@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
}; };
if (formMode === "create") { if (formMode === "create") {
await postApiV10Category(payload); await createCategory({ data: payload });
toast.success("Tạo danh mục thành công"); toast.success("Tạo danh mục thành công");
} else if (formValues.id) { } else if (formValues.id) {
await putApiV10CategoryId(formValues.id, payload); await updateCategory({ id: formValues.id, data: payload });
toast.success("Cập nhật danh mục thành công"); toast.success("Cập nhật danh mục thành công");
} }
...@@ -295,7 +300,7 @@ export default function HeaderConfigPage() { ...@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return; if (!deleteTarget || isSubmitting || isMutating) return;
if (isProtectedHomeCategory(deleteTarget.id)) { if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null); setDeleteTarget(null);
...@@ -305,7 +310,7 @@ export default function HeaderConfigPage() { ...@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await deleteApiV10CategoryId(deleteTarget.id); await deleteCategory({ id: deleteTarget.id });
toast.success("Xóa danh mục thành công"); toast.success("Xóa danh mục thành công");
setDeleteTarget(null); setDeleteTarget(null);
await reload(); await reload();
......
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
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,
...@@ -103,7 +103,7 @@ export function MediaFormDialog({ ...@@ -103,7 +103,7 @@ export function MediaFormDialog({
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]"> <div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]"> <div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]">
{form.previewUrl ? ( {form.previewUrl ? (
<SafeImage <Image
src={form.previewUrl} src={form.previewUrl}
alt={form.name} alt={form.name}
fill fill
......
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -161,7 +161,7 @@ export default function AdminMediaPage() { ...@@ -161,7 +161,7 @@ export default function AdminMediaPage() {
className="group overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_28px_60px_rgba(6,62,142,0.14)]" className="group overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_28px_60px_rgba(6,62,142,0.14)]"
> >
<div className="relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top,#dce9ff_0%,#f8fbff_55%,#ffffff_100%)]"> <div className="relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top,#dce9ff_0%,#f8fbff_55%,#ffffff_100%)]">
<SafeImage <Image
src={resolveCmsFileUrl(item.path)} src={resolveCmsFileUrl(item.path)}
alt={item.original ?? ""} alt={item.original ?? ""}
fill fill
......
...@@ -7,7 +7,6 @@ import { ...@@ -7,7 +7,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { MemberField, MemberRegion } from "@/api/vcci-news/types/member";
import { import {
selectContentClassName, selectContentClassName,
selectItemClassName, selectItemClassName,
...@@ -15,8 +14,8 @@ import { ...@@ -15,8 +14,8 @@ import {
} from "./constants"; } from "./constants";
interface MemberFiltersProps { interface MemberFiltersProps {
fields: MemberField[]; fields: { id: string; name: string }[];
regions: MemberRegion[]; regions: { id: string; name: string }[];
fieldFilter: string; fieldFilter: string;
regionFilter: string; regionFilter: string;
onFieldFilterChange: (value: string) => void; onFieldFilterChange: (value: string) => void;
...@@ -35,11 +34,11 @@ export function MemberFilters({ ...@@ -35,11 +34,11 @@ export function MemberFilters({
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={fieldFilter} onValueChange={onFieldFilterChange}> <Select value={fieldFilter} onValueChange={onFieldFilterChange}>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Lĩnh vực" /> <SelectValue placeholder="Doanh nghiệp" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}> <SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực Tất cả doanh nghiệp
</SelectItem> </SelectItem>
{fields.map((f) => ( {fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}> <SelectItem key={f.id} value={f.id} className={selectItemClassName}>
...@@ -51,11 +50,11 @@ export function MemberFilters({ ...@@ -51,11 +50,11 @@ export function MemberFilters({
<Select value={regionFilter} onValueChange={onRegionFilterChange}> <Select value={regionFilter} onValueChange={onRegionFilterChange}>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Khu vực" /> <SelectValue placeholder="Chức vụ" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}> <SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực Tất cả chức vụ
</SelectItem> </SelectItem>
{regions.map((r) => ( {regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}> <SelectItem key={r.id} value={r.id} className={selectItemClassName}>
......
"use client"; "use client";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
TableCell, TableCell,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { MemberItem } from "@/api/vcci-news/types/member"; import type { AdminMemberRow } from "@/lib/utils/admin-member";
interface MemberRowProps { interface MemberRowProps {
item: MemberItem; item: AdminMemberRow;
index: number; index: number;
fieldName: string | undefined; fieldName: string | undefined;
regionName: string | undefined; regionName: string | undefined;
...@@ -33,30 +33,30 @@ export function MemberRow({ ...@@ -33,30 +33,30 @@ export function MemberRow({
> >
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800"> <TableCell className="px-4 py-3 text-sm font-medium text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div>{item.name}</div> <div>{item.full_name}</div>
{item.is_featured ? ( {item.job_title ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]" className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
> >
Hội viên tiêu biểu {item.job_title}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
{item.image ? ( {item.avatar_url ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15"> <div className="mx-auto h-12 w-12 overflow-hidden rounded-full border border-[#063e8e]/15">
<SafeImage <Image
src={item.image.url} src={item.avatar_url}
alt={item.image.alt || item.name} alt={item.full_name}
width={64} width={48}
height={48} height={48}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
</div> </div>
) : ( ) : (
<div className="mx-auto flex h-12 w-16 items-center justify-center rounded-lg border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400"> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400">
Chưa có Chưa có
</div> </div>
)} )}
...@@ -68,13 +68,10 @@ export function MemberRow({ ...@@ -68,13 +68,10 @@ export function MemberRow({
{fieldName ?? "—"} {fieldName ?? "—"}
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600"> <TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>} <span className="line-clamp-2">{item.job_title || "—"}</span>
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600"> <TableCell className="px-4 py-3 text-center text-sm text-gray-600">
<span className="line-clamp-2">{item.address || "—"}</span> {item.birth_date || "—"}
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
<AdminRowActions <AdminRowActions
......
...@@ -12,19 +12,18 @@ import { ...@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { MemberField } from "@/api/vcci-news/types/member";
const fieldClassName = const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"; "border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps { interface FieldFormDialogProps {
open: boolean; open: boolean;
initial: MemberField | null; initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void; onSave: (data: { id?: string; name: string }) => void;
saving?: boolean;
} }
export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialogProps) { export function FieldFormDialog({ open, initial, onOpenChange, onSave, saving }: FieldFormDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
useEffect(() => { useEffect(() => {
...@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
const handleSave = () => { const handleSave = () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
toast.error("Vui lòng nhập tên lĩnh vực"); toast.error("Vui lòng nhập tên doanh nghiệp");
return; return;
} }
onSave({ id: initial?.id, name: trimmed }); onSave({ id: initial?.id, name: trimmed });
...@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white"> <DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[#063e8e]"> <DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"} {initial ? "Chỉnh sửa doanh nghiệp" : "Thêm doanh nghiệp mới"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700"> <Label className="text-gray-700">
Tên lĩnh vực <span className="text-red-500">*</span> Tên doanh nghiệp <span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên lĩnh vực..." placeholder="Nhập tên doanh nghiệp..."
className={fieldClassName} className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()} onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
...@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
type="button" type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave} onClick={handleSave}
disabled={saving}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Lưu {saving ? "Đang lưu..." : "Lưu"}
</Button> </Button>
</div> </div>
</div> </div>
......
This diff is collapsed.
This diff is collapsed.
...@@ -12,19 +12,18 @@ import { ...@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { MemberRegion } from "@/api/vcci-news/types/member";
const fieldClassName = const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"; "border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps { interface RegionFormDialogProps {
open: boolean; open: boolean;
initial: MemberRegion | null; initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void; onSave: (data: { id?: string; name: string }) => void;
saving?: boolean;
} }
export function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDialogProps) { export function RegionFormDialog({ open, initial, onOpenChange, onSave, saving }: RegionFormDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
useEffect(() => { useEffect(() => {
...@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
const handleSave = () => { const handleSave = () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
toast.error("Vui lòng nhập tên khu vực"); toast.error("Vui lòng nhập tên chức vụ");
return; return;
} }
onSave({ id: initial?.id, name: trimmed }); onSave({ id: initial?.id, name: trimmed });
...@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white"> <DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[#063e8e]"> <DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"} {initial ? "Chỉnh sửa chức vụ" : "Thêm chức vụ mới"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700"> <Label className="text-gray-700">
Tên khu vực <span className="text-red-500">*</span> Tên chức vụ <span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..." placeholder="Nhập tên chức vụ..."
className={fieldClassName} className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()} onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
...@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
type="button" type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave} onClick={handleSave}
disabled={saving}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Lưu {saving ? "Đang lưu..." : "Lưu"}
</Button> </Button>
</div> </div>
</div> </div>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment