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.
This diff is collapsed.
...@@ -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}>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import type { AdminNewsItem } from "@/api/vcci-news/types/post";
type AdminNewsItem, import type {
} from "@/mockdata/admin-news"; HeaderCategoryItem,
import { HeaderCategoryTreeItem,
type HeaderCategoryItem, } from "@/api/vcci-news/types/header-config";
type HeaderCategoryTreeItem,
} from "@/mockdata/header-config";
export type FlattenedHeaderCategory = HeaderCategoryItem & { depth: number }; export type FlattenedHeaderCategory = HeaderCategoryItem & { depth: number };
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
const FALLBACK_HREF = "https://vcci-hcm.org.vn"; const FALLBACK_HREF = "https://vcci-hcm.org.vn";
...@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) { ...@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) {
title="Quảng cáo VCCI HCM" title="Quảng cáo VCCI HCM"
> >
<div className="relative aspect-[16/10]"> <div className="relative aspect-[16/10]">
<SafeImage <Image
src={src} src={src}
alt="Quảng cáo VCCI HCM" alt="Quảng cáo VCCI HCM"
fill fill
......
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