Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
vcci-news-frontend
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Văn Hoàng
vcci-news-frontend
Commits
ef2f7367
Commit
ef2f7367
authored
Oct 02, 2026
by
Phạm Quang Bảo
Browse files
Options
Browse Files
Download
Plain Diff
[tag]0.1-vcci
parents
d3f4f2a6
f25d62b4
Pipeline
#53461
passed with stages
in 52 seconds
Changes
56
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
56 changed files
with
2948 additions
and
2362 deletions
+2948
-2362
banner.ts
src/api/vcci-news/types/banner.ts
+10
-0
file.ts
src/api/vcci-news/types/file.ts
+6
-0
paged-result.ts
src/api/vcci-news/types/paged-result.ts
+8
-0
post-raw.ts
src/api/vcci-news/types/post-raw.ts
+62
-0
video.ts
src/api/vcci-news/types/video.ts
+6
-0
index.tsx
src/app/(main)/(home)/components/advertisements/index.tsx
+23
-14
banner-item.tsx
...main)/(home)/components/banner/components/banner-item.tsx
+20
-0
index.tsx
src/app/(main)/(home)/components/banner/index.tsx
+56
-106
index.tsx
...(main)/(home)/components/business-opportunities/index.tsx
+136
-12
index.tsx
src/app/(main)/(home)/components/events/index.tsx
+204
-72
index.tsx
src/app/(main)/(home)/components/featured-members/index.tsx
+160
-0
index.tsx
src/app/(main)/(home)/components/featured-news/index.tsx
+165
-22
index.tsx
...p/(main)/(home)/components/horizontal-ad-banner/index.tsx
+22
-9
index.tsx
src/app/(main)/(home)/components/member-connection/index.tsx
+251
-0
index.tsx
src/app/(main)/(home)/components/members/index.tsx
+0
-299
index.tsx
src/app/(main)/(home)/components/news/index.tsx
+251
-113
index.tsx
src/app/(main)/(home)/components/partners/index.tsx
+113
-0
index.tsx
src/app/(main)/(home)/components/policies-and-laws/index.tsx
+138
-11
index.tsx
src/app/(main)/(home)/components/video-and-patners/index.tsx
+0
-228
index.tsx
src/app/(main)/(home)/components/videos/index.tsx
+139
-0
use-advertisements.ts
src/app/(main)/(home)/lib/use-advertisements.ts
+0
-30
use-home-posts.ts
src/app/(main)/(home)/lib/use-home-posts.ts
+0
-709
page.tsx
src/app/(main)/(home)/page.tsx
+38
-17
DynamicPageClient.tsx
src/app/(main)/[...slug]/DynamicPageClient.tsx
+1
-25
page.tsx
src/app/(main)/[...slug]/page.tsx
+1
-16
AnPham.tsx
src/app/(main)/[...slug]/static-pages/AnPham.tsx
+1
-1
SiteMap.tsx
src/app/(main)/[...slug]/static-pages/SiteMap.tsx
+0
-152
ThuVienTaiLieu.tsx
src/app/(main)/[...slug]/static-pages/ThuVienTaiLieu.tsx
+1
-1
Video.tsx
src/app/(main)/[...slug]/static-pages/Video.tsx
+0
-158
index.tsx
...app/(main)/[...slug]/templates/news-detail-page/index.tsx
+1
-1
index.tsx
src/app/(main)/[...slug]/templates/news-page/index.tsx
+1
-1
news-listing.tsx
src/app/(main)/news/news-listing.tsx
+232
-0
page.tsx
src/app/(main)/news/page.tsx
+16
-0
page.tsx
src/app/(main)/search/page.tsx
+29
-0
search-form.tsx
src/app/(main)/search/search-form.tsx
+70
-0
search-listing.tsx
src/app/(main)/search/search-listing.tsx
+190
-0
page.tsx
src/app/(main)/site-map/page.tsx
+15
-0
site-map-listing.tsx
src/app/(main)/site-map/site-map-listing.tsx
+112
-0
page.tsx
src/app/(main)/video/page.tsx
+20
-0
video-listing.tsx
src/app/(main)/video/video-listing.tsx
+119
-0
index.tsx
src/components/base/events-calendar/index.tsx
+10
-9
use-event-calendar-posts.ts
...mponents/base/events-calendar/use-event-calendar-posts.ts
+172
-0
index.tsx
src/components/base/pagination-nav/index.tsx
+58
-0
index.tsx
src/components/shared/sidebar-advertisements/index.tsx
+27
-3
common.ts
src/lib/types/common.ts
+0
-33
google.ts
src/lib/types/google.ts
+0
-126
news-detail-response-data.ts
src/lib/types/news-detail-response-data.ts
+0
-30
search-params.ts
src/lib/types/search-params.ts
+0
-3
cn.ts
src/lib/utils/cn.ts
+0
-8
crypto.ts
src/lib/utils/crypto.ts
+0
-55
env-config.ts
src/lib/utils/env-config.ts
+0
-20
error.ts
src/lib/utils/error.ts
+0
-18
generate-slug.ts
src/lib/utils/generate-slug.ts
+0
-11
post.ts
src/lib/utils/post.ts
+53
-0
storage.ts
src/lib/utils/storage.ts
+0
-38
home-posts.ts
src/mockdata/home-posts.ts
+11
-11
No files found.
src/api/vcci-news/types/banner.ts
0 → 100644
View file @
ef2f7367
/**
* Raw banner row trả về từ `GET /api/v1.0/banner`.
*/
export
type
RawBanner
=
{
id
:
string
;
file_id
?:
string
|
null
;
banner_name
?:
string
|
null
;
image_url
?:
string
|
null
;
display_order
?:
number
|
null
;
};
src/api/vcci-news/types/file.ts
0 → 100644
View file @
ef2f7367
/**
* Raw file trả về từ `GET /api/v1.0/file/{id}`.
*/
export
type
RawFile
=
{
path
?:
string
|
null
;
};
src/api/vcci-news/types/paged-result.ts
0 → 100644
View file @
ef2f7367
/**
* Shape chung của response phân trang: `{ responseData: { rows: [...] } }`.
*/
export
type
PagedResult
<
T
>
=
{
responseData
?:
{
rows
?:
T
[];
};
};
src/api/vcci-news/types/post-raw.ts
0 → 100644
View file @
ef2f7367
export
type
RawPostCategory
=
{
id
?:
string
|
null
;
name
?:
string
|
null
;
slug
?:
string
|
null
;
url
?:
string
|
null
;
type
?:
string
|
null
;
};
export
type
RawPostThumbnail
=
{
path
?:
string
|
null
;
original
?:
string
|
null
;
};
export
type
RawPostContentStructure
=
{
post_content
?:
Array
<
{
content
?:
string
|
null
;
}
>
|
null
;
};
/** Post cơ bản (title, slug, dates, categories). */
export
type
RawPost
=
{
id
?:
string
|
null
;
title
?:
string
|
null
;
slug
?:
string
|
null
;
published_at
?:
string
|
null
;
release_at
?:
string
|
null
;
created_at
?:
string
|
null
;
categories
?:
RawPostCategory
[]
|
null
;
};
/** Post kèm thumbnail (featured-news, member-connection). */
export
type
RawPostWithThumbnail
=
RawPost
&
{
thumbnail
?:
RawPostThumbnail
|
null
;
};
/** Post kèm nội dung (summary/content/content_structure). */
export
type
RawPostWithContent
=
RawPost
&
{
summary
?:
string
|
null
;
content
?:
string
|
null
;
content_structure
?:
RawPostContentStructure
|
null
;
};
/** Post sự kiện (kèm thumbnail + started_at). */
export
type
RawEventPost
=
RawPostWithThumbnail
&
RawPostWithContent
&
{
started_at
?:
string
|
null
;
};
/** Post sự kiện đầy đủ cho lịch sự kiện (started/ended/registration/location...). */
export
type
RawEventCalendarPost
=
RawEventPost
&
{
ended_at
?:
string
|
null
;
registration_deadline
?:
string
|
null
;
location
?:
string
|
null
;
participation_fee
?:
string
|
null
;
expired_at
?:
string
|
null
;
is_featured
?:
boolean
|
null
;
is_hidden
?:
boolean
|
null
;
is_active
?:
boolean
|
null
;
status
?:
string
|
null
;
type
?:
string
|
null
;
event_dates
?:
string
[]
|
null
;
};
src/api/vcci-news/types/video.ts
View file @
ef2f7367
...
...
@@ -9,3 +9,9 @@ export interface VideoFormValues {
name
:
string
;
url
:
string
;
}
export
type
RawVideo
=
{
id
?:
string
|
null
;
name
?:
string
|
null
;
url
?:
string
|
null
;
};
src/app/(main)/(home)/components/
quick-link
s/index.tsx
→
src/app/(main)/(home)/components/
advertisement
s/index.tsx
View file @
ef2f7367
'use client'
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
useMemo
}
from
"react"
;
import
{
useAdvertisements
}
from
"@/app/(main)/(home)/lib/use-advertisements"
;
import
links
from
"@/links"
;
import
{
getApiV10AdvertisementPublic
}
from
"@/api/vcci-news/endpoints/advertisement"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
import
links
from
"@/links"
;
import
{
getFallbackImage
}
from
"@/lib/utils/fallback-image"
;
const
FALLBACK_HREF
=
links
.
externalApiOrigin
;
/** Lấy danh sách quảng cáo square từ API public (BE đã lọc ACTIVE, sort sort_order ASC). */
const
fetchSquareAds
=
async
():
Promise
<
Advertisement
[]
>
=>
{
try
{
const
response
=
await
getApiV10AdvertisementPublic
({
type
:
"square"
,
limit
:
20
,
});
return
(
(
response
as
unknown
as
{
responseData
?:
Advertisement
[]
}
|
undefined
)
?.
responseData
??
[]
);
}
catch
(
error
)
{
console
.
warn
(
"[Advertisements] CMS unavailable, using fallback"
,
error
);
return
[];
}
};
function
AdItem
({
item
,
fallbackSrc
}:
{
item
:
Advertisement
;
fallbackSrc
:
string
})
{
const
src
=
item
.
file
?.
path
?
links
.
resolveImageUrl
(
item
.
file
.
path
)
:
fallbackSrc
;
...
...
@@ -56,14 +70,11 @@ function FallbackAdItem({ src }: { src: string }) {
);
}
function
Advertisements
({
count
=
2
,
startIndex
=
0
}:
{
count
?:
number
;
startIndex
?:
number
})
{
const
ads
=
useAdvertisements
(
"square"
);
export
async
function
Advertisements
({
count
=
2
,
startIndex
=
0
}:
{
count
?:
number
;
startIndex
?:
number
})
{
const
ads
=
await
fetchSquareAds
(
);
const
visibleAds
=
ads
.
slice
(
startIndex
,
startIndex
+
count
);
const
fallbackSrcs
=
useMemo
(
()
=>
Array
.
from
({
length
:
count
},
(
_
,
i
)
=>
getFallbackImage
(
i
)),
[
count
],
);
const
fallbackSrcs
=
Array
.
from
({
length
:
count
},
(
_
,
i
)
=>
getFallbackImage
(
i
));
// Luôn render đủ `count` khung hình: vị trí nào API không có data thì dùng random fallback.
const
items
=
Array
.
from
({
length
:
count
},
(
_
,
i
)
=>
{
...
...
@@ -79,5 +90,3 @@ function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIn
</
aside
>
);
}
\ No newline at end of file
export
default
Advertisements
;
src/app/(main)/(home)/components/banner/components/banner-item.tsx
0 → 100644
View file @
ef2f7367
import
Image
from
"next/image"
;
export
type
BannerItemData
=
{
id
:
string
;
alt
:
string
;
src
:
string
;
};
export
function
BannerItem
({
src
,
alt
}:
BannerItemData
)
{
return
(
<
Image
src=
{
src
}
alt=
{
alt
}
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"h-[220px] w-full object-cover sm:h-[320px] md:h-[430px] lg:h-[540px]"
/>
);
}
src/app/(main)/(home)/components/banner/index.tsx
View file @
ef2f7367
"use client"
;
import
Image
from
"next/image"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
{
Autoplay
}
from
"swiper/modules"
;
import
{
Swiper
as
SwiperType
}
from
"swiper/types"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
useRef
}
from
"react"
;
import
"swiper/css"
;
import
{
useGetApiV10Banner
}
from
"@/api/vcci-news/endpoints/banner"
;
import
{
getApiV10Banner
}
from
"@/api/vcci-news/endpoints/banner"
;
import
type
{
RawBanner
}
from
"@/api/vcci-news/types/banner"
;
import
type
{
RawFile
}
from
"@/api/vcci-news/types/file"
;
import
{
getApiV10FileId
}
from
"@/api/vcci-news/endpoints/file"
;
import
{
resolveCmsFileUrl
}
from
"@/lib/utils/file"
;
import
{
useGetApiV10FileId
}
from
"@/api/vcci-news/endpoints/file"
;
import
{
Skeleton
}
from
"@/components/ui/skeleton"
;
import
{
BannerItem
,
type
BannerItemData
}
from
"./components/banner-item"
;
import
"swiper/css"
;
type
ApiEnvelope
<
T
>
=
{
responseData
?:
T
;
data
?:
{
responseData
?:
T
;
};
};
const
getEnvelopeData
=
<
T
,
>
(payload?: ApiEnvelope
<
T
>
) =
>
payload?.responseData ?? payload?.data?.responseData;
type
BannerRow
=
RawBanner
;
type BannerRow =
{
id
:
string
;
file_id
?:
string
|
null
;
banner_name
?:
string
|
null
;
image_url
?:
string
|
null
;
display_order
?:
number
|
null
;
}
;
const
FALLBACK_IMAGE
=
"/thumbnail.png"
;
/**
* Mock banner khi BE /api/banner lỗi hoặc không trả data.
* Dùng ảnh `thumbnail.png` đã có sẵn trong public/.
*/
const
MOCK_BANNER_ROWS
:
BannerRow
[]
=
[
{
id
:
"mock-banner-1"
,
...
...
@@ -55,83 +39,58 @@ const MOCK_BANNER_ROWS: BannerRow[] = [
},
];
function BannerSlideItem(
{
src
,
alt
,
fileId
,
}
:
{
src
:
string
;
alt
:
string
;
fileId
?:
string
|
null
;
}
)
{
const
{
data
:
file
,
isPending
}
=
useGetApiV10FileId
(
fileId
!
,
{
query
:
{
enabled
:
!!
fileId
,
select
:
(
response
)
=>
response
?.
responseData
??
null
,
},
});
const
resolveFileSrc
=
async
(
fileId
?:
string
|
null
):
Promise
<
string
|
null
>
=>
{
if
(
!
fileId
)
return
null
;
if
(
isPending
)
{
return
(
<
Skeleton
className=
"w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px]"
/>
);
try
{
const
response
=
await
getApiV10FileId
(
fileId
);
const
file
=
response
?.
responseData
as
RawFile
|
undefined
;
return
file
?.
path
?
resolveCmsFileUrl
(
file
.
path
)
:
null
;
}
catch
{
return
null
;
}
};
const
url
=
src
?
src
:
file
?
resolveCmsFileUrl
(
file
.
path
)
:
"/thumbnail.png"
;
return
(
<
Image
src=
{
url
}
alt=
{
alt
}
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] object-cover"
/>
const
toBannerItems
=
async
(
rows
:
BannerRow
[]):
Promise
<
BannerItemData
[]
>
=>
Promise
.
all
(
rows
.
map
(
async
(
row
)
=>
({
id
:
row
.
id
,
alt
:
row
.
banner_name
||
"Banner"
,
src
:
row
.
image_url
??
(
await
resolveFileSrc
(
row
.
file_id
))
??
FALLBACK_IMAGE
,
})),
);
}
const Banner = () =
>
{
const
swiperRef
=
useRef
<
SwiperType
|
null
>
(
null
);
const
{
data
:
bannerData
,
isPending
,
isError
}
=
useGetApiV10Banner
(
{
const
fetchBannerItems
=
async
():
Promise
<
BannerItemData
[]
>
=>
{
try
{
const
response
=
await
getApiV10Banner
({
filters
:
"status@=ACTIVE"
,
sortField
:
"display_order"
,
sortOrder
:
"asc"
,
},
{
query
:
{
staleTime
:
60
*
1000
,
},
},
);
const
pageData
=
bannerData
?
getEnvelopeData
<
{
rows
?:
BannerRow
[]
}
>
(
bannerData
as
unknown
as
ApiEnvelope
<
{
rows
?:
BannerRow
[]
}
>
)
:
undefined
;
// BE trả rows rỗng HOẶC bị lỗi → fallback mock banner để UI không trống.
const
rows
:
BannerRow
[]
=
pageData
?.
rows
&&
pageData
.
rows
.
length
>
0
?
pageData
.
rows
:
MOCK_BANNER_ROWS
;
});
if
(
isPending
&&
!
rows
.
length
)
{
return
(
<
div
className=
"w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] bg-slate-100 flex items-center justify-center"
>
<
Skeleton
className=
"w-full h-full"
/>
</
div
>
);
const
rows
=
(
response
?.
responseData
?.
rows
??
[])
as
BannerRow
[];
return
await
toBannerItems
(
rows
.
length
>
0
?
rows
:
MOCK_BANNER_ROWS
);
}
catch
{
return
await
toBannerItems
(
MOCK_BANNER_ROWS
);
}
};
export
function
Banner
()
{
const
swiperRef
=
useRef
<
SwiperType
|
null
>
(
null
);
if
(
!
rows
||
rows
.
length
===
0
)
{
const
{
data
:
items
=
[],
isPending
}
=
useQuery
({
queryKey
:
[
"banner-items"
,
"status-active"
]
as
const
,
queryFn
:
fetchBannerItems
,
staleTime
:
60
*
1000
,
});
if
(
isPending
)
{
return
(
<
div
className=
"
w-full h-[220px] sm:h-[320px] md:h-[430px] lg:h-[540px] bg-slate-100 flex items-center justify-center
"
>
<
Skeleton
className=
"
w-full h
-full"
/>
<
div
className=
"
flex h-[220px] w-full items-center justify-center bg-slate-100 sm:h-[320px] md:h-[430px] lg:h-[540px]
"
>
<
Skeleton
className=
"
h-full w
-full"
/>
</
div
>
);
}
...
...
@@ -140,25 +99,16 @@ const Banner = () => {
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
loop=
{
row
s
.
length
>
1
}
loop=
{
item
s
.
length
>
1
}
slidesPerView=
{
1
}
onSwiper=
{
(
s
)
=>
(
swiperRef
.
current
=
s
)
}
className=
"w-full overflow-hidden"
>
{
rows
.
map
((
row
)
=>
{
const
src
=
row
.
image_url
??
(
row
.
file_id
?
undefined
:
"/thumbnail.png"
);
return
(
<
SwiperSlide
key=
{
row
.
id
}
>
<
BannerSlideItem
src=
{
src
??
""
}
alt=
{
row
.
banner_name
||
"Banner"
}
fileId=
{
row
.
file_id
??
null
}
/>
{
items
.
map
((
item
)
=>
(
<
SwiperSlide
key=
{
item
.
id
}
>
<
BannerItem
{
...
item
}
/>
</
SwiperSlide
>
);
})
}
))
}
</
Swiper
>
);
}
;
export default Banner;
}
src/app/(main)/(home)/components/business-opportunities/index.tsx
View file @
ef2f7367
'use client'
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawPost
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
{
buildPostLink
,
normalizeLink
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
function
BusinessOpportunities
()
{
const
{
businessPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
businessPosts
[
index
]
??
null
);
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
coHoiKinhDoanh
.
toLowerCase
())
??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh"
;
const
CATEGORY_ID
=
"0a460499-89c1-4f52-8592-1fb7bb69c4a2"
;
const
SECTION_LINK_FALLBACK
=
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh"
;
const
SECTION_PARAMS
=
{
page
:
1
,
pageSize
:
4
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==
${
CATEGORY_ID
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
}
as
const
;
/** Item đã chuẩn hóa mà component cần để render. */
type
BusinessOpportunityItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
};
const
toBusinessOpportunityItem
=
(
post
:
RawPost
):
BusinessOpportunityItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
undefined
,
post
.
id
?
String
(
post
.
id
)
:
""
,
"#"
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
};
};
/** Đọc URL category "Cơ hội kinh doanh" từ bài viết đầu tiên (nếu có). */
const
resolveSectionLink
=
(
posts
:
RawPost
[])
=>
{
const
categoryUrl
=
posts
[
0
]?.
categories
?.
find
(
(
category
)
=>
category
?.
url
&&
category
.
url
!==
"#"
,
)?.
url
;
return
categoryUrl
?
normalizeLink
(
categoryUrl
)
:
SECTION_LINK_FALLBACK
;
};
const
fetchBusinessOpportunityPosts
=
async
():
Promise
<
{
posts
:
BusinessOpportunityItem
[];
sectionLink
:
string
;
}
>
=>
{
try
{
const
response
=
await
getApiV10Post
(
SECTION_PARAMS
);
const
rows
=
(
response
as
PagedResult
<
RawPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
{
posts
:
rows
.
map
(
toBusinessOpportunityItem
),
sectionLink
:
resolveSectionLink
(
rows
),
};
}
catch
(
error
)
{
// eslint-disable-next-line no-console
console
.
warn
(
"[BusinessOpportunities] CMS unavailable, falling back to mock data"
,
error
,
);
const
mockPosts
=
MOCK_HOME_POSTS
.
filter
((
item
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
id
===
CATEGORY_ID
),
);
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
})),
sectionLink
:
mockPosts
[
0
]?.
categories
.
find
(
(
category
)
=>
category
.
id
===
CATEGORY_ID
&&
category
.
url
!==
"#"
,
)?.
url
??
SECTION_LINK_FALLBACK
,
};
}
};
export
function
BusinessOpportunitiesSkeleton
()
{
return
(
<
section
className=
"flex flex-1 flex-col"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
...
...
@@ -21,6 +113,40 @@ function BusinessOpportunities() {
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
ChevronRight
className=
"h-5 w-5 text-[#24469c]"
/>
</
div
>
<
div
className=
"flex min-h-[270px] flex-1 flex-col gap-2.5"
>
{
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
(
<
div
key=
{
`business-skeleton-${index}`
}
className=
{
`flex min-h-[58px] gap-3 rounded-[16px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`
}
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40"
/>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"h-5 w-5/6 animate-pulse rounded bg-[#eef3fb]"
/>
<
div
className=
"mt-1.5 h-4 w-24 animate-pulse rounded bg-[#f4f7fb]"
/>
</
div
>
</
div
>
))
}
</
div
>
</
section
>
);
}
export
async
function
BusinessOpportunities
()
{
const
{
posts
,
sectionLink
}
=
await
fetchBusinessOpportunityPosts
();
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
posts
[
index
]
??
null
);
return
(
<
section
className=
"flex flex-1 flex-col"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Cơ hội kinh doanh
</
h2
>
<
div
className=
"mt-2.5 h-1 w-10 rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
{
sectionLink
}
...
...
@@ -76,5 +202,3 @@ function BusinessOpportunities() {
</
section
>
);
}
\ No newline at end of file
export
default
BusinessOpportunities
;
src/app/(main)/(home)/components/events/index.tsx
View file @
ef2f7367
'use client'
;
import
Image
from
"next/image"
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawEventPost
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
{
buildPostLink
,
normalizeLink
,
resolveAssetUrl
,
stripHtml
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
Link
from
"next/link"
;
import
{
useMemo
}
from
"react"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
getFallbackImage
}
from
"@/lib/utils/fallback-image"
;
function
Events
()
{
const
{
eventPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
// Reverse so newest event is first (featured card)
const
eventItems
=
[...
eventPosts
].
reverse
();
const
SU_KIEN_CATEGORY_ID
=
"b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a"
;
const
DAO_TAO_CATEGORY_ID
=
"36df7021-9a74-43d6-9084-0d5ed347b7f4"
;
const
SECTION_LINK_FALLBACK
=
"/hoat-dong/su-kien"
;
const
PAGE_SIZE
=
5
;
/** Multi-category filter (OR) — sắp xếp & lọc hoàn toàn ở BE. */
const
SECTION_PARAMS
=
{
page
:
1
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==(
${
SU_KIEN_CATEGORY_ID
}
|
${
DAO_TAO_CATEGORY_ID
}
)`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
}
as
const
;
type
EventItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
startedAt
:
string
;
contentText
:
string
;
summary
:
string
;
/** Text đã strip HTML (caption/figure/img/tag/entity). */
excerpt
:
string
;
categoryName
:
string
;
thumbnail
:
{
url
:
string
;
alt
:
string
}
|
null
;
};
const
toEventItem
=
(
post
:
RawEventPost
):
EventItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
const
categories
=
post
.
categories
??
[];
const
thumbnailPath
=
post
.
thumbnail
?.
path
??
post
.
thumbnail
?.
original
??
null
;
const
contentText
=
String
(
post
.
content_structure
?.
post_content
?.[
0
]?.
content
??
post
.
summary
??
post
.
content
??
""
,
);
const
summary
=
String
(
post
.
summary
??
post
.
content
??
""
);
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
post
.
id
?
String
(
post
.
id
)
:
""
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
startedAt
:
String
(
post
.
started_at
??
""
),
contentText
,
summary
,
excerpt
:
stripHtml
(
contentText
||
summary
),
categoryName
:
String
(
categories
[
0
]?.
name
??
""
),
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
}
:
null
,
};
};
/** Lấy url danh mục sự kiện/đào tạo từ chính danh sách posts, fallback nếu không có. */
const
resolveSectionLink
=
(
posts
:
RawEventPost
[])
=>
{
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
(
item
?.
id
===
SU_KIEN_CATEGORY_ID
||
item
?.
id
===
DAO_TAO_CATEGORY_ID
)
&&
item
?.
url
&&
item
.
url
!==
"#"
,
);
if
(
category
?.
url
)
return
normalizeLink
(
category
.
url
,
SECTION_LINK_FALLBACK
);
}
return
SECTION_LINK_FALLBACK
;
};
const
isEventCategoryId
=
(
id
?:
string
|
null
)
=>
id
===
SU_KIEN_CATEGORY_ID
||
id
===
DAO_TAO_CATEGORY_ID
;
const
fetchEventPosts
=
async
():
Promise
<
{
posts
:
EventItem
[];
sectionLink
:
string
;
}
>
=>
{
try
{
const
response
=
await
getApiV10Post
(
SECTION_PARAMS
);
const
rows
=
(
response
as
PagedResult
<
RawEventPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
{
posts
:
rows
.
map
(
toEventItem
),
sectionLink
:
resolveSectionLink
(
rows
),
};
}
catch
(
error
)
{
console
.
warn
(
"[Events] CMS unavailable, falling back to mock data"
,
error
);
const
mockPosts
=
MOCK_HOME_POSTS
.
filter
((
item
)
=>
item
.
categories
.
some
((
category
)
=>
isEventCategoryId
(
category
.
id
)),
);
const
mockSectionLink
=
mockPosts
.
flatMap
((
item
)
=>
item
.
categories
)
.
find
((
category
)
=>
isEventCategoryId
(
category
.
id
)
&&
category
.
url
!==
"#"
)
?.
url
??
SECTION_LINK_FALLBACK
;
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
startedAt
:
item
.
startedAt
,
contentText
:
item
.
contentText
,
summary
:
item
.
summary
,
excerpt
:
stripHtml
(
item
.
contentText
||
item
.
summary
),
categoryName
:
item
.
categories
[
0
]?.
name
??
""
,
thumbnail
:
item
.
thumbnail
,
})),
sectionLink
:
mockSectionLink
,
};
}
};
export
function
EventsSkeleton
()
{
return
(
<
div
className=
"flex-1 rounded-[16px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5"
>
<
div
className=
"mb-4 flex items-center justify-between gap-4"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-white"
>
Sự kiện sắp diễn ra
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[60px] rounded-full bg-[#f7b500]"
/>
</
div
>
</
div
>
<
div
className=
"grid items-stretch gap-3 md:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]"
>
<
div
className=
"flex h-full flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]"
>
<
div
className=
"h-[180px] animate-pulse bg-[#d7e3f9] md:h-[220px] xl:h-[248px]"
/>
<
div
className=
"space-y-2 p-3 pt-2.5"
>
<
div
className=
"h-6 w-5/6 animate-pulse rounded bg-[#e7eefb]"
/>
<
div
className=
"h-4 w-24 animate-pulse rounded bg-[#eef3fb]"
/>
</
div
>
</
div
>
<
div
className=
"grid h-full grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-1"
>
{
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
(
<
div
key=
{
`events-skeleton-${index}`
}
className=
"flex flex-1 items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<
div
className=
"h-[64px] w-[64px] shrink-0 animate-pulse rounded-[14px] bg-white/20"
/>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"h-5 w-5/6 animate-pulse rounded bg-white/25"
/>
<
div
className=
"mt-2 h-3 w-20 animate-pulse rounded bg-white/20"
/>
</
div
>
</
div
>
))
}
</
div
>
</
div
>
</
div
>
);
}
export
async
function
Events
()
{
const
{
posts
,
sectionLink
}
=
await
fetchEventPosts
();
// Giữ nguyên hành vi cũ: đảo ngược danh sách trước khi chọn bài featured
const
eventItems
=
[...
posts
].
reverse
();
const
[
featuredEvent
,
...
sideEvents
]
=
eventItems
;
const
sideSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
sideEvents
[
index
]
??
null
);
const
eventsLink
=
categoryLinks
.
get
(
categoryNames
.
suKien
.
toLowerCase
())
??
"/hoat-dong/su-kien"
;
const
featuredFallback
=
useMemo
(()
=>
getFallbackImage
(
0
),
[]);
const
sideFallbacks
=
useMemo
(
()
=>
Array
.
from
({
length
:
4
},
(
_
,
i
)
=>
getFallbackImage
(
i
+
1
)),
[],
);
const
featuredFallback
=
getFallbackImage
(
0
);
const
sideFallbacks
=
Array
.
from
({
length
:
4
},
(
_
,
i
)
=>
getFallbackImage
(
i
+
1
));
return
(
<
div
className=
"flex-1 rounded-[16px] bg-linear-to-br from-[#14488f] to-[#2d67bf] p-4 text-white shadow-[0_18px_38px_rgba(16,61,130,0.24)] md:p-5"
>
...
...
@@ -34,7 +206,7 @@ function Events() {
</
div
>
<
Link
href=
{
events
Link
}
href=
{
section
Link
}
className=
"text-[#ffd34f] transition-colors hover:text-white"
>
<
ChevronRight
className=
"h-5 w-5"
/>
...
...
@@ -56,7 +228,7 @@ function Events() {
className=
"h-full w-full object-cover"
/>
<
span
className=
"absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]"
>
{
featuredEvent
.
categor
ies
[
0
]?.
n
ame
||
"Sự kiện"
}
{
featuredEvent
.
categor
yN
ame
||
"Sự kiện"
}
</
span
>
</
div
>
...
...
@@ -64,30 +236,11 @@ function Events() {
<
h3
className=
"text-[16px] font-bold uppercase leading-[1.28] text-[#22459b] line-clamp-2 transition-colors duration-200 group-hover:text-[#f7b500] md:text-[18px]"
>
{
featuredEvent
.
title
}
</
h3
>
{
(()
=>
{
const
rawText
=
featuredEvent
.
contentText
||
featuredEvent
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
return
textOnly
.
length
>
10
?
(
{
featuredEvent
.
excerpt
.
length
>
10
?
(
<
p
className=
"mt-2 line-clamp-1 text-[13px] leading-normal text-[#5f6f86]"
>
{
textOnly
.
substring
(
0
,
150
)
}
{
featuredEvent
.
excerpt
.
substring
(
0
,
150
)
}
</
p
>
)
:
null
;
})()
}
)
:
null
}
<
p
className=
"mt-auto pt-2 text-[13px] text-[#90a0bd]"
>
{
dayjs
(
featuredEvent
.
startedAt
||
featuredEvent
.
publishedAt
||
featuredEvent
.
createdAt
,
...
...
@@ -127,37 +280,18 @@ function Events() {
<
h4
className=
"line-clamp-1 text-[15px] font-semibold leading-[1.35] text-white transition-colors duration-200 group-hover:text-[#f7b500]"
>
{
item
.
title
}
</
h4
>
{
(()
=>
{
const
rawText
=
item
.
contentText
||
item
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
return
textOnly
.
length
>
10
?
(
{
item
.
excerpt
.
length
>
10
?
(
<
p
className=
"mt-1 line-clamp-1 text-[12px] text-white/78"
>
{
textOnly
.
substring
(
0
,
80
)
}
{
item
.
excerpt
.
substring
(
0
,
80
)
}
</
p
>
)
:
null
;
})()
}
)
:
null
}
<
div
className=
"mt-1 flex items-center gap-2"
>
{
item
.
categor
ies
[
0
]?.
n
ame
&&
(
{
item
.
categor
yN
ame
&&
(
<
span
className=
"text-[12px] font-medium text-[#f7b500]"
>
{
item
.
categor
ies
[
0
].
n
ame
}
{
item
.
categor
yN
ame
}
</
span
>
)
}
{
item
.
categor
ies
[
0
]?.
n
ame
&&
(
{
item
.
categor
yN
ame
&&
(
<
span
className=
"text-[12px] text-white/50"
>
•
</
span
>
)
}
<
p
className=
"text-[12px] text-white/78"
>
...
...
@@ -186,5 +320,3 @@ function Events() {
</
div
>
);
}
export
default
Events
;
src/app/(main)/(home)/components/featured-members/index.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
getOrganizations
}
from
"@/api/vcci-hcm/endpoints/organizations"
;
import
type
{
Organization
}
from
"@/api/vcci-hcm/models"
;
import
memberImages
from
"@/constants/memberImages"
;
import
{
MOCK_FEATURED_MEMBERS_RESPONSE
}
from
"@/mockdata/bff-fallback"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
Autoplay
}
from
"swiper/modules"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
"swiper/css"
;
const
VCCI_HCM_SITE_URL
=
"https://vccihcm.vn"
;
const
FEATURED_MEMBER_MORE_URL
=
`
${
VCCI_HCM_SITE_URL
}
/giao-thuong-b2b?filters=users.status_id+%3D%3D+36ca1cc5-7b6e-4f9f-b973-69c5207deb62&sortField=created_at&sortOrder=ASC`
;
const
MOCK_FEATURED_MEMBER_ROWS
=
MOCK_FEATURED_MEMBERS_RESPONSE
.
responseData
?.
rows
as
unknown
as
Organization
[]
??
[];
const
resolveMemberImage
=
(
avatar
:
string
|
null
|
undefined
,
index
:
number
)
=>
{
if
(
avatar
?.
startsWith
(
"http://"
)
||
avatar
?.
startsWith
(
"https://"
))
{
return
avatar
;
}
if
(
avatar
?.
startsWith
(
"/"
))
{
return
`
${
VCCI_HCM_SITE_URL
}${
avatar
}
`
;
}
return
memberImages
[
index
%
memberImages
.
length
]
??
"/img-error.png"
;
};
const
getMemberDetailUrl
=
(
orgLink
:
string
|
null
|
undefined
)
=>
{
if
(
orgLink
)
{
return
`
${
VCCI_HCM_SITE_URL
}
/giao-thuong-b2b/doanh-nghiep/
${
orgLink
}
`
;
}
return
null
;
};
/** Khối "Hội viên tiêu biểu": dữ liệu từ Organizations API, bắt buộc client vì Swiper autoplay. */
export
function
FeaturedMembers
()
{
const
{
data
:
organizationsResponse
,
isLoading
:
featuredMembersLoading
}
=
useQuery
<
Organization
[]
|
undefined
>
({
queryKey
:
[
"featured-members"
,
"status-36ca1cc5-7b6e-4f9f-b973-69c5207deb62"
,
]
as
const
,
queryFn
:
async
()
=>
{
const
response
=
await
getOrganizations
({
filters
:
"users.status_id==36ca1cc5-7b6e-4f9f-b973-69c5207deb62"
,
pageSize
:
"12"
,
sortField
:
"created_at"
,
sortOrder
:
"DESC"
,
});
const
rows
=
((
response
as
any
)?.
responseData
?.
rows
??
[])
as
Organization
[];
return
rows
.
length
>
0
?
rows
:
MOCK_FEATURED_MEMBER_ROWS
;
},
staleTime
:
60
*
1000
,
});
const
featuredMembers
=
organizationsResponse
??
MOCK_FEATURED_MEMBER_ROWS
;
const
displayMembers
=
featuredMembers
.
slice
(
0
,
9
);
return
(
<
aside
className=
"flex-1 rounded-[16px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5"
>
<
div
className=
"flex items-center justify-between gap-3 pb-10"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#20449a]"
>
Hội viên tiêu biểu
</
h2
>
<
div
className=
"mt-2.5 h-1 w-10 rounded-full bg-white"
/>
</
div
>
<
Link
href=
{
FEATURED_MEMBER_MORE_URL
}
target=
"_blank"
rel=
"noreferrer"
className=
"text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
{
featuredMembersLoading
?
(
<
div
className=
"rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70"
>
Đang tải dữ liệu...
</
div
>
)
:
displayMembers
.
length
===
0
?
(
<
div
className=
"rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70"
>
Chưa có thông tin.
</
div
>
)
:
(
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4200
,
disableOnInteraction
:
false
}
}
observer
observeParents
updateOnWindowResize
slidesPerView=
"auto"
spaceBetween=
{
16
}
className=
"w-full"
>
{
displayMembers
.
map
((
member
,
index
)
=>
{
const
detailUrl
=
getMemberDetailUrl
(
member
.
org_link
);
return
(
<
SwiperSlide
key=
{
member
.
id
}
className=
"!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<
article
className=
"rounded-[14px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]"
>
{
detailUrl
?
(
<
a
href=
{
detailUrl
}
target=
"_blank"
rel=
"noreferrer"
className=
"block"
>
<
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"
>
<
Image
src=
{
resolveMemberImage
(
member
.
avatar
,
index
)
}
alt=
{
member
.
name
??
""
}
width=
{
260
}
height=
{
180
}
className=
"h-[180px] w-[260px] max-w-full object-contain"
/>
</
div
>
</
div
>
<
h3
className=
"mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
{
member
.
name
}
</
h3
>
</
a
>
)
:
(
<>
<
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"
>
<
Image
src=
{
resolveMemberImage
(
member
.
avatar
,
index
)
}
alt=
{
member
.
name
??
""
}
width=
{
260
}
height=
{
180
}
className=
"h-[180px] w-[260px] max-w-full object-contain"
/>
</
div
>
</
div
>
<
h3
className=
"mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e]"
>
{
member
.
name
}
</
h3
>
</>
)
}
</
article
>
</
SwiperSlide
>
);
})
}
</
Swiper
>
)
}
</
aside
>
);
}
src/app/(main)/(home)/components/featured-news/index.tsx
View file @
ef2f7367
'use client'
;
import
Image
from
"next/image"
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawPostWithThumbnail
,
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
{
buildPostLink
,
normalizeLink
,
resolveAssetUrl
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
useMemo
}
from
"react"
;
import
HorizontalAdBanner
from
"@/app/(main)/(home)/components/horizontal-ad-banner"
;
import
{
HorizontalAdBanner
}
from
"@/app/(main)/(home)/components/horizontal-ad-banner"
;
import
{
getFallbackImage
}
from
"@/lib/utils/fallback-image"
;
const
FALLBACK_CATEGORY_LINK
=
"/hoat-dong/tin-tuc"
;
const
TIN_VCCI_CATEGORY_ID
=
"b89b2ba6-a699-47cb-87e4-0643aea549a9"
;
const
SECTION_LINK_FALLBACK
=
"/hoat-dong/tin-tuc"
;
const
PAGE_SIZE
=
3
;
/** Featured posts: is_featured==true, ưu tiên bài featured, sort created_at desc ở BE. */
const
SECTION_PARAMS
=
{
page
:
1
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
true
,
filters
:
[
"is_featured==true"
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
].
join
(
","
),
}
as
const
;
type
FeaturedNewsItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
categoryName
:
string
;
thumbnail
:
{
url
:
string
;
alt
:
string
}
|
null
;
};
const
toFeaturedNewsItem
=
(
post
:
RawPostWithThumbnail
):
FeaturedNewsItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
const
thumbnailPath
=
post
.
thumbnail
?.
path
??
post
.
thumbnail
?.
original
??
null
;
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
post
.
id
?
String
(
post
.
id
)
:
""
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
categoryName
:
String
(
post
.
categories
?.[
0
]?.
name
??
""
),
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
}
:
null
,
};
};
/** Link danh mục Tin VCCI lấy từ chính danh sách posts, fallback nếu không có. */
const
resolveSectionLink
=
(
posts
:
RawPostWithThumbnail
[])
=>
{
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
item
?.
id
===
TIN_VCCI_CATEGORY_ID
&&
item
?.
url
&&
item
.
url
!==
"#"
,
);
if
(
category
?.
url
)
return
normalizeLink
(
category
.
url
,
SECTION_LINK_FALLBACK
);
}
return
SECTION_LINK_FALLBACK
;
};
const
fetchFeaturedNewsPosts
=
async
():
Promise
<
{
posts
:
FeaturedNewsItem
[];
sectionLink
:
string
;
}
>
=>
{
try
{
const
response
=
await
getApiV10Post
(
SECTION_PARAMS
);
const
rows
=
(
response
as
PagedResult
<
RawPostWithThumbnail
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
{
posts
:
rows
.
map
(
toFeaturedNewsItem
),
sectionLink
:
resolveSectionLink
(
rows
),
};
}
catch
(
error
)
{
console
.
warn
(
"[FeaturedNews] CMS unavailable, falling back to mock data"
,
error
);
const
mockPosts
=
MOCK_HOME_POSTS
.
filter
(
(
item
)
=>
item
.
isFeatured
&&
!
item
.
isHidden
,
).
slice
(
0
,
PAGE_SIZE
);
const
mockSectionLink
=
mockPosts
.
flatMap
((
item
)
=>
item
.
categories
)
.
find
((
category
)
=>
category
.
id
===
TIN_VCCI_CATEGORY_ID
&&
category
.
url
!==
"#"
)
?.
url
??
SECTION_LINK_FALLBACK
;
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
categoryName
:
item
.
categories
[
0
]?.
name
??
""
,
thumbnail
:
item
.
thumbnail
,
})),
sectionLink
:
mockSectionLink
,
};
}
};
export
function
FeaturedNewsSkeleton
()
{
const
primaryFallback
=
getFallbackImage
(
0
);
return
(
<
section
className=
"py-8 md:py-10"
>
<
div
className=
"w-full"
>
<
div
className=
"mb-8 flex items-center justify-between gap-4"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Tin nổi bật
</
h2
>
<
div
className=
"mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]"
/>
</
div
>
</
div
>
<
div
className=
"grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]"
>
<
div
className=
"relative overflow-hidden rounded-[16px] bg-[#e9eef8] shadow-[0_16px_32px_rgba(28,52,120,0.12)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]"
>
<
div
className=
"flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-80 md:p-5 lg:min-h-[380px]"
>
<
span
className=
"mb-2 h-8 w-28 animate-pulse rounded-[10px] bg-white/80"
/>
<
div
className=
"h-8 w-3/4 animate-pulse rounded bg-white/90 md:h-10"
/>
<
div
className=
"mt-2 h-5 w-28 animate-pulse rounded bg-white/70"
/>
</
div
>
</
div
>
function
FeaturedNews
()
{
const
{
featuredPosts
,
categoryNames
,
categoryLinks
}
=
useHomePosts
();
const
featuredNewsItems
=
featuredPosts
.
slice
(
0
,
3
);
<
div
className=
"flex flex-col gap-4 lg:min-h-[380px]"
>
<
div
className=
"grid flex-1 gap-4 md:grid-cols-2"
>
{
Array
.
from
({
length
:
2
},
(
_
,
index
)
=>
(
<
div
key=
{
`featured-news-skeleton-${index}`
}
className=
"rounded-[16px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
>
<
div
className=
"flex h-full min-h-[165px] flex-col justify-end p-3.5"
>
<
span
className=
"mb-2 h-7 w-24 animate-pulse rounded-[10px] bg-white/80"
/>
<
div
className=
"h-6 w-5/6 animate-pulse rounded bg-white/90"
/>
<
div
className=
"mt-2 h-4 w-24 animate-pulse rounded bg-white/70"
/>
</
div
>
</
div
>
))
}
</
div
>
<
HorizontalAdBanner
/>
</
div
>
</
div
>
</
div
>
</
section
>
);
}
export
async
function
FeaturedNews
()
{
const
{
posts
,
sectionLink
}
=
await
fetchFeaturedNewsPosts
();
const
featuredNewsItems
=
posts
.
slice
(
0
,
3
);
const
[
primaryItem
,
...
secondaryItems
]
=
featuredNewsItems
;
const
secondarySlots
=
Array
.
from
({
length
:
2
},
(
_
,
index
)
=>
secondaryItems
[
index
]
??
null
);
const
featuredOverviewLink
=
categoryLinks
.
get
(
categoryNames
.
tinVcci
.
toLowerCase
())
??
FALLBACK_CATEGORY_LINK
;
const
primaryFallback
=
useMemo
(()
=>
getFallbackImage
(
0
),
[]);
const
secondaryFallbacks
=
useMemo
(
()
=>
[
getFallbackImage
(
1
),
getFallbackImage
(
2
)],
[],
);
const
primaryFallback
=
getFallbackImage
(
0
);
const
secondaryFallbacks
=
[
getFallbackImage
(
1
),
getFallbackImage
(
2
)];
return
(
<
section
className=
"py-8 md:py-10"
>
...
...
@@ -37,7 +182,7 @@ function FeaturedNews() {
</
div
>
<
Link
href=
{
featuredOverview
Link
}
href=
{
section
Link
}
className=
"text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
...
...
@@ -62,7 +207,7 @@ function FeaturedNews() {
<
div
className=
"relative flex h-full flex-col justify-end p-3.5 md:p-5"
>
<
span
className=
"mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]"
>
{
primaryItem
.
categor
ies
[
0
]?.
n
ame
||
"Tin nổi bật"
}
{
primaryItem
.
categor
yN
ame
||
"Tin nổi bật"
}
</
span
>
<
h3
className=
"max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[22px] lg:text-[24px]"
>
...
...
@@ -108,7 +253,7 @@ function FeaturedNews() {
<
div
className=
"relative flex h-full flex-col justify-end p-3.5"
>
<
span
className=
"mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]"
>
{
item
.
categor
ies
[
0
]?.
n
ame
||
"Tin nổi bật"
}
{
item
.
categor
yN
ame
||
"Tin nổi bật"
}
</
span
>
<
h4
className=
"line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:text-[17px]"
>
...
...
@@ -145,5 +290,3 @@ function FeaturedNews() {
</
section
>
);
}
export
default
FeaturedNews
;
src/app/(main)/(home)/components/horizontal-ad-banner/index.tsx
View file @
ef2f7367
'use client'
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
useAdvertisements
}
from
"@/app/(main)/(home)/lib/use-advertisements"
;
import
{
getApiV10AdvertisementPublic
}
from
"@/api/vcci-news/endpoints/advertisement"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
import
links
from
"@/links"
;
const
FALLBACK_HREF
=
links
.
externalApiOrigin
;
const
FALLBACK_SRC
=
"/quang-cao/qc-1.jpg"
;
function
HorizontalAdBanner
()
{
const
ads
=
useAdvertisements
(
"horizontal"
);
const
ad
=
ads
[
0
];
/** Lấy 1 quảng cáo horizontal từ API public (BE đã lọc ACTIVE, sort sort_order ASC). */
const
fetchHorizontalAd
=
async
():
Promise
<
Advertisement
|
null
>
=>
{
try
{
const
response
=
await
getApiV10AdvertisementPublic
({
type
:
"horizontal"
,
limit
:
1
,
});
const
rows
=
(
response
as
unknown
as
{
responseData
?:
Advertisement
[]
}
|
undefined
)
?.
responseData
??
[];
return
rows
[
0
]
??
null
;
}
catch
(
error
)
{
console
.
warn
(
"[HorizontalAdBanner] CMS unavailable, using fallback"
,
error
);
return
null
;
}
};
export
async
function
HorizontalAdBanner
()
{
const
ad
=
await
fetchHorizontalAd
();
const
href
=
ad
?.
link
||
FALLBACK_HREF
;
const
src
=
ad
?.
file
?.
path
?
links
.
resolveImageUrl
(
ad
.
file
.
path
)
:
FALLBACK_SRC
;
...
...
@@ -37,5 +52,3 @@ function HorizontalAdBanner() {
</
Link
>
);
}
\ No newline at end of file
export
default
HorizontalAdBanner
;
src/app/(main)/(home)/components/member-connection/index.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
useGetApiV10Post
,
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawPostWithThumbnail
}
from
"@/api/vcci-news/types/post-raw"
;
import
{
buildPostLink
,
normalizeLink
,
resolveAssetUrl
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
{
Autoplay
}
from
"swiper/modules"
;
const
KET_NOI_HOI_VIEN_CATEGORY_ID
=
"a37b8a02-e8b3-42ce-9225-6dae460fed99"
;
const
SECTION_LINK_FALLBACK
=
"/hoi-vien/ket-noi-hoi-vien"
;
const
MEMBER_CONNECTION_FALLBACK_IMAGE
=
"/home/20-2048x1365.webp"
;
const
PAGE_SIZE
=
2
;
/** Params lấy 2 bài "Kết nối hội viên" — sort & filter ở BE. */
const
CONNECTION_POSTS_PARAMS
=
{
page
:
1
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==
${
KET_NOI_HOI_VIEN_CATEGORY_ID
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
}
as
const
;
type
RawConnectionPost
=
RawPostWithThumbnail
&
{
};
export
type
MemberConnectionPost
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
categoryName
:
string
;
thumbnail
:
{
url
:
string
;
alt
:
string
}
|
null
;
};
const
toConnectionPost
=
(
post
:
RawConnectionPost
):
MemberConnectionPost
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
const
thumbnailPath
=
post
.
thumbnail
?.
path
??
post
.
thumbnail
?.
original
??
null
;
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
post
.
id
?
String
(
post
.
id
)
:
""
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
categoryName
:
String
(
post
.
categories
?.[
0
]?.
name
??
""
),
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
}
:
null
,
};
};
/** Link danh mục Kết nối hội viên từ chính posts, fallback nếu không có. */
const
resolveSectionLink
=
(
posts
:
RawConnectionPost
[])
=>
{
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
item
?.
id
===
KET_NOI_HOI_VIEN_CATEGORY_ID
&&
item
?.
url
&&
item
.
url
!==
"#"
,
);
if
(
category
?.
url
)
return
normalizeLink
(
category
.
url
,
SECTION_LINK_FALLBACK
);
}
return
SECTION_LINK_FALLBACK
;
};
const
toMockConnectionPosts
=
():
MemberConnectionPost
[]
=>
MOCK_HOME_POSTS
.
filter
((
item
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
id
===
KET_NOI_HOI_VIEN_CATEGORY_ID
),
)
.
slice
(
0
,
PAGE_SIZE
)
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
categoryName
:
item
.
categories
[
0
]?.
name
??
""
,
thumbnail
:
item
.
thumbnail
,
}));
const
toConnectionPostsResult
=
(
response
:
unknown
):
{
posts
:
MemberConnectionPost
[];
sectionLink
:
string
;
}
=>
{
const
rows
=
(
response
as
{
responseData
?:
{
rows
?:
RawConnectionPost
[]
}
}
|
undefined
)
?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
{
return
{
posts
:
toMockConnectionPosts
(),
sectionLink
:
SECTION_LINK_FALLBACK
,
};
}
return
{
posts
:
rows
.
map
(
toConnectionPost
),
sectionLink
:
resolveSectionLink
(
rows
),
};
};
const
renderConnectionCard
=
(
item
:
MemberConnectionPost
,
aspectClass
=
"aspect-[16/10] overflow-hidden"
)
=>
(
<>
<
div
className=
{
aspectClass
}
>
<
Image
src=
{
item
.
thumbnail
?.
url
??
MEMBER_CONNECTION_FALLBACK_IMAGE
}
alt=
{
item
.
thumbnail
?.
alt
||
item
.
title
}
width=
{
520
}
height=
{
420
}
className=
"h-full w-full object-cover object-[center_80%]"
/>
</
div
>
<
div
className=
"absolute inset-0 bg-linear-to-t from-[#0d2f5f]/85 via-[#0d2f5f]/30 to-transparent"
/>
<
div
className=
"absolute inset-x-0 bottom-0 p-4"
>
<
h4
className=
"line-clamp-2 text-[15px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500]"
>
{
item
.
title
}
</
h4
>
<
div
className=
"mt-1.5 flex items-center gap-2"
>
{
item
.
categoryName
&&
(
<>
<
span
className=
"text-[12px] font-medium text-[#f7b500]"
>
{
item
.
categoryName
}
</
span
>
<
span
className=
"text-[12px] text-white/50"
>
•
</
span
>
</>
)
}
<
p
className=
"text-[12px] font-medium text-white/78"
>
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</>
);
const
CONNECTION_CARD_LINK_CLASS
=
"group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
;
export
function
MemberConnectionSkeleton
()
{
return
(
<
aside
className=
"w-full xl:w-[31%] xl:min-w-[320px]"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Kết nối hội viên
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
Array
.
from
({
length
:
2
},
(
_
,
index
)
=>
(
<
div
key=
{
`member-connection-skeleton-${index}`
}
className=
"h-[220px] animate-pulse rounded-[14px] bg-[#dde5f3]"
/>
))
}
</
div
>
</
aside
>
);
}
/** Khối "Kết nối hội viên": client component dùng hook orval (mobile dùng Swiper). */
export
function
MemberConnection
()
{
// Dùng hook orval useGetApiV10Post: queryKey tự sinh, retry, devtools sẵn.
const
connectionPostsQuery
=
useGetApiV10Post
(
CONNECTION_POSTS_PARAMS
,
{
query
:
{
staleTime
:
60
*
1000
,
select
:
toConnectionPostsResult
,
},
});
const
connectionPosts
=
connectionPostsQuery
.
data
?.
posts
??
[];
const
sectionLink
=
connectionPostsQuery
.
data
?.
sectionLink
??
SECTION_LINK_FALLBACK
;
return
(
<
aside
className=
"w-full xl:w-[31%] xl:min-w-[320px]"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Kết nối hội viên
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
{
sectionLink
}
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
>
{
connectionPosts
.
length
>
0
?
(
<>
{
/* Mobile + xl+: Swiper */
}
<
div
className=
"md:hidden xl:block"
>
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
loop=
{
connectionPosts
.
length
>
1
}
slidesPerView=
{
1
}
className=
"w-full overflow-hidden rounded-[14px]"
>
{
connectionPosts
.
map
((
item
)
=>
(
<
SwiperSlide
key=
{
item
.
id
}
>
<
Link
href=
{
item
.
externalLink
}
className=
{
CONNECTION_CARD_LINK_CLASS
}
>
{
renderConnectionCard
(
item
,
"aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]"
)
}
</
Link
>
</
SwiperSlide
>
))
}
</
Swiper
>
</
div
>
{
/* md to < xl: Grid 2 columns */
}
<
div
className=
"hidden gap-4 md:grid md:grid-cols-2 xl:hidden"
>
{
connectionPosts
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
externalLink
}
className=
{
CONNECTION_CARD_LINK_CLASS
}
>
{
renderConnectionCard
(
item
,
"aspect-[16/10] overflow-hidden"
)
}
</
Link
>
))
}
</
div
>
</>
)
:
(
<
div
className=
"rounded-[14px] bg-[#eef3fb] px-5 py-10 text-center text-sm text-[#7f8eab]"
>
Chưa có thông tin.
</
div
>
)
}
</
div
>
</
aside
>
);
}
src/app/(main)/(home)/components/members/index.tsx
deleted
100644 → 0
View file @
d3f4f2a6
'use client'
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
Image
from
"next/image"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
memberImages
from
"@/constants/memberImages"
;
import
{
MOCK_FEATURED_MEMBERS_RESPONSE
}
from
"@/mockdata/bff-fallback"
;
import
{
useGetOrganizations
}
from
"@/api/vcci-hcm/endpoints/organizations"
;
import
type
{
Organization
}
from
"@/api/vcci-hcm/models"
;
import
Link
from
"next/link"
;
import
{
Autoplay
}
from
"swiper/modules"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
"swiper/css"
;
import
dayjs
from
"dayjs"
;
const
MEMBER_CONNECTION_FALLBACK_IMAGE
=
"/home/20-2048x1365.webp"
;
const
VCCI_HCM_SITE_URL
=
"https://vccihcm.vn"
;
const
FEATURED_MEMBER_MORE_URL
=
`
${
VCCI_HCM_SITE_URL
}
/giao-thuong-b2b?filters=users.status_id+%3D%3D+36ca1cc5-7b6e-4f9f-b973-69c5207deb62&sortField=created_at&sortOrder=ASC`
;
const
MOCK_FEATURED_MEMBER_ROWS
=
MOCK_FEATURED_MEMBERS_RESPONSE
.
responseData
?.
rows
as
unknown
as
Organization
[]
??
[];
const
resolveMemberImage
=
(
avatar
:
string
|
null
|
undefined
,
index
:
number
)
=>
{
if
(
avatar
?.
startsWith
(
"http://"
)
||
avatar
?.
startsWith
(
"https://"
))
{
return
avatar
;
}
if
(
avatar
?.
startsWith
(
"/"
))
{
return
`
${
VCCI_HCM_SITE_URL
}${
avatar
}
`
;
}
return
memberImages
[
index
%
memberImages
.
length
]
??
"/img-error.png"
;
};
const
getMemberDetailUrl
=
(
orgLink
:
string
|
null
|
undefined
)
=>
{
if
(
orgLink
)
{
return
`
${
VCCI_HCM_SITE_URL
}
/giao-thuong-b2b/doanh-nghiep/
${
orgLink
}
`
;
}
return
null
;
};
function
Members
()
{
const
{
memberConnectionPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
connectionPosts
=
memberConnectionPosts
.
slice
(
0
,
2
);
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
ketNoiHoiVien
.
toLowerCase
())
??
"/hoi-vien/ket-noi-hoi-vien"
;
const
{
data
:
organizationsResponse
,
isLoading
:
featuredMembersLoading
}
=
useGetOrganizations
<
Organization
[]
|
undefined
>
(
{
filters
:
"users.status_id==36ca1cc5-7b6e-4f9f-b973-69c5207deb62"
,
pageSize
:
"12"
,
sortField
:
"created_at"
,
sortOrder
:
"ASC"
,
},
{
query
:
{
staleTime
:
60
*
1000
,
select
:
(
response
)
=>
{
const
rows
=
(
response
as
any
)?.
responseData
?.
rows
??
[];
return
rows
.
length
>
0
?
rows
:
MOCK_FEATURED_MEMBER_ROWS
;
},
},
},
);
const
featuredMembers
=
organizationsResponse
??
MOCK_FEATURED_MEMBER_ROWS
;
const
displayMembers
=
featuredMembers
.
slice
(
0
,
9
);
const
renderMemberContent
=
()
=>
{
if
(
featuredMembersLoading
)
{
return
(
<
div
className=
"rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70"
>
Đang tải dữ liệu...
</
div
>
);
}
if
(
displayMembers
.
length
===
0
)
{
return
(
<
div
className=
"rounded-[14px] bg-white/40 px-5 py-10 text-center text-sm text-[#1e2f5e]/70"
>
Chưa có thông tin.
</
div
>
);
}
return
(
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4200
,
disableOnInteraction
:
false
}
}
observer
observeParents
updateOnWindowResize
slidesPerView=
"auto"
spaceBetween=
{
16
}
className=
"w-full"
>
{
displayMembers
.
map
((
member
,
index
)
=>
{
const
detailUrl
=
getMemberDetailUrl
(
member
.
org_link
);
return
(
<
SwiperSlide
key=
{
member
.
id
}
className=
"!h-auto !w-full md:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
<
article
className=
"rounded-[14px] bg-white p-[7px] shadow-[0_10px_22px_rgba(158,114,0,0.16)]"
>
{
detailUrl
?
(
<
a
href=
{
detailUrl
}
target=
"_blank"
rel=
"noreferrer"
className=
"block"
>
<
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"
>
<
Image
src=
{
resolveMemberImage
(
member
.
avatar
,
index
)
}
alt=
{
member
.
name
??
""
}
width=
{
260
}
height=
{
180
}
className=
"h-[180px] w-[260px] max-w-full object-contain"
/>
</
div
>
</
div
>
<
h3
className=
"mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
{
member
.
name
}
</
h3
>
</
a
>
)
:
(
<>
<
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"
>
<
Image
src=
{
resolveMemberImage
(
member
.
avatar
,
index
)
}
alt=
{
member
.
name
??
""
}
width=
{
260
}
height=
{
180
}
className=
"h-[180px] w-[260px] max-w-full object-contain"
/>
</
div
>
</
div
>
<
h3
className=
"mt-3 line-clamp-2 min-h-[40px] px-1 text-center text-sm font-semibold leading-5 text-[#1e2f5e]"
>
{
member
.
name
}
</
h3
>
</>
)
}
</
article
>
</
SwiperSlide
>
);
})
}
</
Swiper
>
);
};
return
(
<
section
className=
"flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"
>
<
aside
className=
"flex-1 rounded-[16px] bg-[#f7b500] p-4 shadow-[0_18px_34px_rgba(247,181,0,0.18)] md:p-5"
>
<
div
className=
"flex items-center justify-between gap-3 pb-10"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#20449a]"
>
Hội viên tiêu biểu
</
h2
>
<
div
className=
"mt-2.5 h-1 w-10 rounded-full bg-white"
/>
</
div
>
<
Link
href=
{
FEATURED_MEMBER_MORE_URL
}
target=
"_blank"
rel=
"noreferrer"
className=
"text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
{
renderMemberContent
()
}
</
aside
>
<
aside
className=
"w-full xl:w-[31%] xl:min-w-[320px]"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Kết nối hội viên
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
{
sectionLink
}
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
>
{
connectionPosts
.
length
>
0
?
(
<>
{
/* Mobile + xl+: Swiper */
}
<
div
className=
"md:hidden xl:block"
>
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
loop=
{
connectionPosts
.
length
>
1
}
slidesPerView=
{
1
}
className=
"w-full overflow-hidden rounded-[14px]"
>
{
connectionPosts
.
map
((
item
)
=>
(
<
SwiperSlide
key=
{
item
.
id
}
>
<
Link
href=
{
item
.
externalLink
}
className=
"group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<
div
className=
"aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]"
>
<
Image
src=
{
item
.
thumbnail
?.
url
??
MEMBER_CONNECTION_FALLBACK_IMAGE
}
alt=
{
item
.
thumbnail
?.
alt
||
item
.
title
}
width=
{
520
}
height=
{
420
}
className=
"h-full w-full object-cover object-[center_80%]"
/>
</
div
>
<
div
className=
"absolute inset-0 bg-linear-to-t from-[#0d2f5f]/85 via-[#0d2f5f]/30 to-transparent"
/>
<
div
className=
"absolute inset-x-0 bottom-0 p-4"
>
<
h4
className=
"line-clamp-2 text-[15px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500]"
>
{
item
.
title
}
</
h4
>
<
div
className=
"mt-1.5 flex items-center gap-2"
>
{
item
.
categories
[
0
]?.
name
&&
(
<>
<
span
className=
"text-[12px] font-medium text-[#f7b500]"
>
{
item
.
categories
[
0
].
name
}
</
span
>
<
span
className=
"text-[12px] text-white/50"
>
•
</
span
>
</>
)
}
<
p
className=
"text-[12px] font-medium text-white/78"
>
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
</
SwiperSlide
>
))
}
</
Swiper
>
</
div
>
{
/* md to < xl: Grid 2 columns */
}
<
div
className=
"hidden gap-4 md:grid md:grid-cols-2 xl:hidden"
>
{
connectionPosts
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
externalLink
}
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"
>
<
Image
src=
{
item
.
thumbnail
?.
url
??
MEMBER_CONNECTION_FALLBACK_IMAGE
}
alt=
{
item
.
thumbnail
?.
alt
||
item
.
title
}
width=
{
520
}
height=
{
420
}
className=
"h-full w-full object-cover object-[center_80%]"
/>
</
div
>
<
div
className=
"absolute inset-0 bg-linear-to-t from-[#0d2f5f]/85 via-[#0d2f5f]/30 to-transparent"
/>
<
div
className=
"absolute inset-x-0 bottom-0 p-4"
>
<
h4
className=
"line-clamp-2 text-[15px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500]"
>
{
item
.
title
}
</
h4
>
<
div
className=
"mt-1.5 flex items-center gap-2"
>
{
item
.
categories
[
0
]?.
name
&&
(
<>
<
span
className=
"text-[12px] font-medium text-[#f7b500]"
>
{
item
.
categories
[
0
].
name
}
</
span
>
<
span
className=
"text-[12px] text-white/50"
>
•
</
span
>
</>
)
}
<
p
className=
"text-[12px] font-medium text-white/78"
>
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
))
}
</
div
>
</>
)
:
(
<
div
className=
"rounded-[14px] bg-[#eef3fb] px-5 py-10 text-center text-sm text-[#7f8eab]"
>
Chưa có thông tin.
</
div
>
)
}
</
div
>
</
aside
>
</
section
>
);
}
export
default
Members
;
src/app/(main)/(home)/components/news/index.tsx
View file @
ef2f7367
'use client'
;
import
Image
from
"next/image"
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawPostWithContent
}
from
"@/api/vcci-news/types/post-raw"
;
import
{
buildPostLink
,
normalizeLink
,
resolveAssetUrl
,
stripHtml
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
Link
from
"next/link"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
useMemo
,
useState
}
from
"react"
;
const
tabs
=
[
...
...
@@ -14,37 +22,245 @@ const tabs = [
{
id
:
"chuyen-de"
,
label
:
"Chuyên đề"
},
];
function
News
()
{
const
NEWS_SECTION_LINK_FALLBACK
=
"/hoat-dong/tin-tuc"
;
const
NEWS_PAGE_SIZE
=
6
;
const
NEWS_CATEGORY_IDS
=
{
tinVcci
:
"b89b2ba6-a699-47cb-87e4-0643aea549a9"
,
tinKinhTe
:
"755106b6-1aca-47dc-9a9c-d434736c33a1"
,
chuyenDe
:
"8e7090e5-bfc3-4128-81a5-37ec78c33bad"
,
}
as
const
;
const
NEWS_CATEGORY_ALIASES
=
{
tinVcci
:
[
"Tin VCCI"
,
"tin-vcci"
],
tinKinhTe
:
[
"Tin Kinh tế"
,
"tin-kinh-te"
],
chuyenDe
:
[
"Chuyên đề"
,
"chuyen-de"
],
}
as
const
;
type
NewsCategoryKey
=
keyof
typeof
NEWS_CATEGORY_IDS
;
type
RawNewsPost
=
RawPostWithContent
&
{
thumbnail
?:
{
path
?:
string
|
null
;
original
?:
string
|
null
}
|
null
;
};
type
NewsPostItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
summary
:
string
;
contentText
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
categoryName
:
string
;
thumbnail
:
{
url
:
string
;
alt
:
string
}
|
null
;
};
const
toNewsPostItem
=
(
post
:
RawNewsPost
):
NewsPostItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
const
thumbnailPath
=
post
.
thumbnail
?.
path
??
post
.
thumbnail
?.
original
??
null
;
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
post
.
id
?
String
(
post
.
id
)
:
""
,
),
summary
:
String
(
post
.
summary
??
post
.
content
??
""
),
contentText
:
String
(
post
.
content_structure
?.
post_content
?.[
0
]?.
content
??
post
.
summary
??
post
.
content
??
""
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
categoryName
:
String
(
post
.
categories
?.[
0
]?.
name
??
""
),
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
}
:
null
,
};
};
/** Link danh mục tương ứng từ chính posts, fallback nếu không có. */
const
resolveSectionLink
=
(
posts
:
RawNewsPost
[],
categoryId
:
string
,
aliases
:
readonly
string
[],
)
=>
{
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
item
?.
id
===
categoryId
&&
item
?.
url
&&
item
.
url
!==
"#"
&&
item
.
url
!==
"/"
,
);
if
(
category
?.
url
)
return
normalizeLink
(
category
.
url
,
NEWS_SECTION_LINK_FALLBACK
);
}
// Fallback: khớp theo tên/slug alias.
const
aliasSlugs
=
aliases
.
map
((
alias
)
=>
alias
.
trim
()
.
toLowerCase
()
.
normalize
(
"NFD"
)
.
replace
(
/
[\u
0300-
\u
036f
]
/g
,
""
)
.
replace
(
/đ/g
,
"d"
)
.
replace
(
/
[^
a-z0-9
]
+/g
,
"-"
)
.
replace
(
/^-+|-+$/g
,
""
),
);
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
item
?.
url
&&
item
.
url
!==
"#"
&&
item
.
url
!==
"/"
&&
aliasSlugs
.
some
((
slug
)
=>
(
item
.
slug
??
""
).
toLowerCase
().
includes
(
slug
)
||
item
.
url
!
.
toLowerCase
().
includes
(
slug
),
),
);
if
(
category
?.
url
)
return
normalizeLink
(
category
.
url
,
NEWS_SECTION_LINK_FALLBACK
);
}
return
NEWS_SECTION_LINK_FALLBACK
;
};
const
matchesNewsAliases
=
(
item
:
(
typeof
MOCK_HOME_POSTS
)[
number
],
categoryId
:
string
,
aliases
:
readonly
string
[],
)
=>
item
.
categories
.
some
(
(
category
)
=>
category
.
id
===
categoryId
||
aliases
.
some
((
alias
)
=>
category
.
name
.
toLowerCase
()
===
alias
.
toLowerCase
()),
);
const
toNewsTabResult
=
(
response
:
unknown
,
categoryId
:
string
,
aliases
:
readonly
string
[],
):
{
posts
:
NewsPostItem
[];
sectionLink
:
string
}
=>
{
const
rows
=
(
response
as
{
responseData
?:
{
rows
?:
RawNewsPost
[]
}
}
|
undefined
)
?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
{
// Fallback mock khi BE lỗi hoặc trả rỗng.
const
mockPosts
=
MOCK_HOME_POSTS
.
filter
((
item
)
=>
matchesNewsAliases
(
item
,
categoryId
,
aliases
),
).
slice
(
0
,
NEWS_PAGE_SIZE
);
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
summary
:
item
.
summary
,
contentText
:
item
.
contentText
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
categoryName
:
item
.
categories
[
0
]?.
name
??
""
,
thumbnail
:
item
.
thumbnail
,
})),
sectionLink
:
NEWS_SECTION_LINK_FALLBACK
,
};
}
return
{
posts
:
rows
.
map
(
toNewsPostItem
),
sectionLink
:
resolveSectionLink
(
rows
,
categoryId
,
aliases
),
};
};
function
useNewsTabPosts
(
key
:
NewsCategoryKey
)
{
const
categoryId
=
NEWS_CATEGORY_IDS
[
key
];
const
aliases
=
NEWS_CATEGORY_ALIASES
[
key
];
return
useGetApiV10Post
(
{
page
:
1
,
pageSize
:
NEWS_PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==
${
categoryId
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
},
{
query
:
{
staleTime
:
60
*
1000
,
select
:
(
response
:
unknown
)
=>
toNewsTabResult
(
response
,
categoryId
,
aliases
),
},
},
);
}
const
uniquePosts
=
(
items
:
NewsPostItem
[])
=>
{
const
seen
=
new
Set
<
string
>
();
return
items
.
filter
((
item
)
=>
{
if
(
!
item
.
id
||
seen
.
has
(
item
.
id
))
return
false
;
seen
.
add
(
item
.
id
);
return
true
;
});
};
export
function
News
()
{
const
[
tab
,
setTab
]
=
useState
(
"all"
);
const
{
newsTabs
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
tinVcciQuery
=
useNewsTabPosts
(
"tinVcci"
);
const
tinKinhTeQuery
=
useNewsTabPosts
(
"tinKinhTe"
);
const
chuyenDeQuery
=
useNewsTabPosts
(
"chuyenDe"
);
const
tinVcciPosts
=
tinVcciQuery
.
data
?.
posts
??
[];
const
tinKinhTePosts
=
tinKinhTeQuery
.
data
?.
posts
??
[];
const
chuyenDePosts
=
chuyenDeQuery
.
data
?.
posts
??
[];
const
allPosts
=
useMemo
(
()
=>
uniquePosts
([...
tinVcciPosts
,
...
tinKinhTePosts
,
...
chuyenDePosts
]),
[
tinVcciPosts
,
tinKinhTePosts
,
chuyenDePosts
],
);
const
filteredItems
=
useMemo
(()
=>
{
if
(
tab
===
"all"
)
return
newsTabs
.
all
;
if
(
tab
===
"tin-kinh-te"
)
return
newsTabs
.
tinKinhTe
;
if
(
tab
===
"chuyen-de"
)
return
newsTabs
.
chuyenDe
;
return
newsTabs
.
tinVcci
;
},
[
newsTab
s
,
tab
]);
if
(
tab
===
"all"
)
return
allPosts
;
if
(
tab
===
"tin-kinh-te"
)
return
tinKinhTePosts
;
if
(
tab
===
"chuyen-de"
)
return
chuyenDePosts
;
return
tinVcciPosts
;
},
[
allPosts
,
tinKinhTePosts
,
chuyenDePosts
,
tinVcciPost
s
,
tab
]);
const
featuredArticle
=
filteredItems
[
0
]
??
newsTabs
.
all
[
0
];
const
overviewLink
=
useMemo
(()
=>
{
if
(
tab
===
"tin-kinh-te"
)
return
tinKinhTeQuery
.
data
?.
sectionLink
??
NEWS_SECTION_LINK_FALLBACK
;
if
(
tab
===
"chuyen-de"
)
return
chuyenDeQuery
.
data
?.
sectionLink
??
NEWS_SECTION_LINK_FALLBACK
;
return
tinVcciQuery
.
data
?.
sectionLink
??
NEWS_SECTION_LINK_FALLBACK
;
},
[
tinVcciQuery
.
data
,
tinKinhTeQuery
.
data
,
chuyenDeQuery
.
data
,
tab
]);
const
featuredArticle
=
filteredItems
[
0
]
??
allPosts
[
0
];
const
listArticles
=
filteredItems
.
slice
(
1
,
5
);
const
overviewLink
=
(
tab
===
"all"
?
categoryLinks
.
get
(
categoryNames
.
tinVcci
.
toLowerCase
())
:
tab
===
"tin-kinh-te"
?
categoryLinks
.
get
(
categoryNames
.
tinKinhTe
.
toLowerCase
())
:
tab
===
"chuyen-de"
?
categoryLinks
.
get
(
categoryNames
.
chuyenDe
.
toLowerCase
())
:
categoryLinks
.
get
(
categoryNames
.
tinVcci
.
toLowerCase
()))
??
"/hoat-dong/tin-tuc"
;
return
(
<
div
className=
"flex-1"
>
<
div
className=
"mb-6 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between"
>
<
div
className=
"flex items-center gap-2"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Tin tức
</
h2
>
<
div
className=
"mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
"/news"
aria
-
label=
"Xem tất cả tin tức"
className=
"text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-3 xl:justify-end"
>
{
tabs
.
map
((
item
)
=>
{
...
...
@@ -91,7 +307,7 @@ function News() {
className=
"h-full w-full object-cover"
/>
<
span
className=
"absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]"
>
{
featuredArticle
.
categor
ies
[
0
]?.
n
ame
||
"Tin tức"
}
{
featuredArticle
.
categor
yN
ame
||
"Tin tức"
}
</
span
>
</
div
>
...
...
@@ -101,26 +317,7 @@ function News() {
</
h3
>
<
p
className=
"line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]"
>
{
(()
=>
{
const
rawText
=
featuredArticle
.
contentText
||
featuredArticle
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
return
textOnly
||
"-"
;
})()
}
{
stripHtml
(
featuredArticle
.
contentText
||
featuredArticle
.
summary
)
||
"-"
}
</
p
>
<
p
className=
"text-[14px] text-[#8a9bb6]"
>
...
...
@@ -165,23 +362,7 @@ function News() {
{
news
.
title
}
</
h4
>
{
(()
=>
{
const
rawText
=
news
.
contentText
||
news
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
const
textOnly
=
stripHtml
(
news
.
contentText
||
news
.
summary
);
return
textOnly
.
length
>
10
?
(
<
p
className=
"mt-1 line-clamp-1 text-[13px] leading-normal text-[#6c7b96]"
>
{
textOnly
}
...
...
@@ -190,9 +371,9 @@ function News() {
})()
}
<
div
className=
"mt-1 flex items-center gap-2"
>
<
span
className=
"text-[12px] font-medium text-[#e2a500]"
>
{
news
.
categor
ies
[
0
]?.
name
||
""
}
{
news
.
categor
yName
}
</
span
>
{
news
.
categor
ies
[
0
]?.
n
ame
&&
(
{
news
.
categor
yN
ame
&&
(
<
span
className=
"text-[12px] text-[#8a9bb6]"
>
•
</
span
>
)
}
<
p
className=
"text-[13px] text-[#8a9bb6]"
>
...
...
@@ -235,7 +416,7 @@ function News() {
className=
"h-full w-full object-cover"
/>
<
span
className=
"absolute left-3 top-3 inline-flex rounded-full bg-[#f7b500] px-3 py-1 text-[12px] font-bold text-[#15357a]"
>
{
featuredArticle
.
categor
ies
[
0
]?.
n
ame
||
"Tin tức"
}
{
featuredArticle
.
categor
yN
ame
||
"Tin tức"
}
</
span
>
</
div
>
...
...
@@ -244,23 +425,7 @@ function News() {
{
featuredArticle
.
title
}
</
h3
>
{
(()
=>
{
const
rawText
=
featuredArticle
.
contentText
||
featuredArticle
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
const
textOnly
=
stripHtml
(
featuredArticle
.
contentText
||
featuredArticle
.
summary
);
return
textOnly
.
length
>
10
?
(
<
p
className=
"line-clamp-2 text-[13px] leading-[1.45] text-[#6c7b96]"
>
{
textOnly
}
...
...
@@ -306,23 +471,7 @@ function News() {
{
news
.
title
}
</
h4
>
{
(()
=>
{
const
rawText
=
news
.
contentText
||
news
.
summary
||
""
;
const
textOnly
=
rawText
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
const
textOnly
=
stripHtml
(
news
.
contentText
||
news
.
summary
);
return
textOnly
.
length
>
10
?
(
<
p
className=
"mt-1 line-clamp-1 text-[13px] leading-normal text-[#6c7b96]"
>
{
textOnly
}
...
...
@@ -330,10 +479,10 @@ function News() {
)
:
null
;
})()
}
<
div
className=
"mt-1 flex items-center gap-2"
>
{
news
.
categor
ies
[
0
]?.
n
ame
&&
(
{
news
.
categor
yN
ame
&&
(
<>
<
span
className=
"text-[12px] font-medium text-[#e2a500]"
>
{
news
.
categor
ies
[
0
].
n
ame
}
{
news
.
categor
yN
ame
}
</
span
>
<
span
className=
"text-[12px] text-[#8a9bb6]"
>
•
</
span
>
</>
...
...
@@ -359,17 +508,6 @@ function News() {
})
}
</
div
>
</
div
>
{
/* <div className="mt-4 hidden justify-end xl:flex">
<Link
href={overviewLink}
className="text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
</Link>
</div> */
}
</
div
>
);
}
\ No newline at end of file
export
default
News
;
src/app/(main)/(home)/components/partners/index.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
getOrganizations
}
from
"@/api/vcci-hcm/endpoints/organizations"
;
import
type
{
Organization
}
from
"@/api/vcci-hcm/models"
;
import
partnerImages
from
"@/constants/partnerImages"
;
import
{
MOCK_PARTNERS_RESPONSE
}
from
"@/mockdata/bff-fallback"
;
import
Image
from
"next/image"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
Autoplay
}
from
"swiper/modules"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
"swiper/css"
;
const
VCCI_HCM_SITE_URL
=
"https://vccihcm.vn"
;
const
resolvePartnerImage
=
(
avatar
:
string
|
null
|
undefined
,
index
:
number
)
=>
{
if
(
avatar
?.
startsWith
(
"http://"
)
||
avatar
?.
startsWith
(
"https://"
))
{
return
avatar
;
}
if
(
avatar
?.
startsWith
(
"/"
))
{
return
`
${
VCCI_HCM_SITE_URL
}${
avatar
}
`
;
}
return
partnerImages
[
index
%
partnerImages
.
length
]
??
"/img-error.png"
;
};
const
MOCK_PARTNER_ROWS
=
MOCK_PARTNERS_RESPONSE
.
responseData
.
rows
as
unknown
as
Organization
[];
export
function
Partners
()
{
const
{
data
:
partnersResponse
}
=
useQuery
<
Organization
[]
|
undefined
>
({
queryKey
:
[
"home-partners"
,
"type-sponsor"
]
as
const
,
queryFn
:
async
()
=>
{
const
response
=
await
getOrganizations
({
filters
:
"type==SPONSOR"
,
pageSize
:
"12"
,
sortField
:
"sort_order"
,
sortOrder
:
"ASC"
,
});
const
rows
=
((
response
as
any
)?.
responseData
?.
rows
??
[])
as
Organization
[];
return
rows
.
length
>
0
?
rows
:
MOCK_PARTNER_ROWS
;
},
staleTime
:
60
*
1000
,
});
const
partners
=
(
partnersResponse
??
MOCK_PARTNER_ROWS
).
slice
(
0
,
12
);
return
(
<
aside
className=
"flex w-full flex-col xl:w-[43%]"
>
<
div
className=
"mb-5 flex items-start justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Đối tác
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
</
div
>
{
partners
.
length
>
0
?
(
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4200
,
disableOnInteraction
:
false
}
}
observer
observeParents
updateOnWindowResize
slidesPerView=
"auto"
spaceBetween=
{
16
}
className=
"w-full"
>
{
partners
.
map
((
partner
,
index
)
=>
(
<
SwiperSlide
key=
{
partner
.
id
}
className=
"!h-auto !w-full sm:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
{
partner
.
website
?
(
<
a
href=
{
partner
.
website
}
target=
"_blank"
rel=
"noreferrer"
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]"
>
<
Image
src=
{
resolvePartnerImage
(
partner
.
avatar
,
index
)
}
alt=
{
partner
.
name
??
""
}
width=
{
140
}
height=
{
72
}
className=
"max-h-full w-full object-contain"
/>
</
div
>
</
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]"
>
<
Image
src=
{
resolvePartnerImage
(
partner
.
avatar
,
index
)
}
alt=
{
partner
.
name
??
""
}
width=
{
140
}
height=
{
72
}
className=
"max-h-full w-full object-contain"
/>
</
div
>
)
}
</
SwiperSlide
>
))
}
</
Swiper
>
)
:
(
<
div
className=
"rounded-2xl border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500"
>
Chưa có thông tin.
</
div
>
)
}
</
aside
>
);
}
\ No newline at end of file
src/app/(main)/(home)/components/policies-and-laws/index.tsx
View file @
ef2f7367
'use client'
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawPost
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
{
buildPostLink
,
normalizeLink
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
function
PolicyAndLaws
()
{
const
{
policyPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
policyPosts
[
index
]
??
null
);
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
chinhSachPhapLuat
.
toLowerCase
())
??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat"
;
const
CATEGORY_ID
=
"cc448be9-b9ea-46a8-aa7b-0584803330e8"
;
const
SECTION_LINK_FALLBACK
=
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat"
;
const
SECTION_PARAMS
=
{
page
:
1
,
pageSize
:
4
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==
${
CATEGORY_ID
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
}
as
const
;
/** Item đã chuẩn hóa mà component cần để render. */
type
PolicyAndLawsItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
};
const
toPolicyAndLawsItem
=
(
post
:
RawPost
):
PolicyAndLawsItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
return
{
id
:
String
(
post
.
id
??
""
),
title
,
externalLink
:
buildPostLink
(
post
.
slug
?
`/
${
post
.
slug
}
`
:
undefined
,
post
.
id
?
String
(
post
.
id
)
:
""
,
"#"
,
),
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
};
};
/** Đọc URL category "Chính sách và Pháp luật" từ bài viết đầu tiên (nếu có). */
const
resolveSectionLink
=
(
posts
:
RawPost
[])
=>
{
const
categoryUrl
=
posts
[
0
]?.
categories
?.
find
(
(
category
)
=>
category
?.
url
&&
category
.
url
!==
"#"
,
)?.
url
;
return
categoryUrl
?
normalizeLink
(
categoryUrl
)
:
SECTION_LINK_FALLBACK
;
};
const
fetchPolicyAndLawsPosts
=
async
():
Promise
<
{
posts
:
PolicyAndLawsItem
[];
sectionLink
:
string
;
}
>
=>
{
try
{
const
response
=
await
getApiV10Post
(
SECTION_PARAMS
);
const
rows
=
(
response
as
PagedResult
<
RawPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
{
posts
:
rows
.
map
(
toPolicyAndLawsItem
),
sectionLink
:
resolveSectionLink
(
rows
),
};
}
catch
(
error
)
{
// eslint-disable-next-line no-console
console
.
warn
(
"[PolicyAndLaws] CMS unavailable, falling back to mock data"
,
error
,
);
const
mockPosts
=
MOCK_HOME_POSTS
.
filter
((
item
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
id
===
CATEGORY_ID
),
);
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
})),
sectionLink
:
mockPosts
[
0
]?.
categories
.
find
(
(
category
)
=>
category
.
id
===
CATEGORY_ID
&&
category
.
url
!==
"#"
,
)?.
url
??
SECTION_LINK_FALLBACK
,
};
}
};
export
function
PolicyAndLawsSkeleton
()
{
return
(
<
section
className=
"flex flex-1 flex-col"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Chính sách
&
pháp luật
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
ChevronRight
className=
"h-5 w-5 text-[#24469c]"
/>
</
div
>
<
div
className=
"flex min-h-[270px] flex-1 flex-col gap-2.5"
>
{
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
(
<
div
key=
{
`policy-skeleton-${index}`
}
className=
{
`flex min-h-[58px] gap-3 rounded-[16px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`
}
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40"
/>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"h-5 w-5/6 animate-pulse rounded bg-[#eef3fb]"
/>
<
div
className=
"mt-1.5 h-4 w-24 animate-pulse rounded bg-[#f4f7fb]"
/>
</
div
>
</
div
>
))
}
</
div
>
</
section
>
);
}
export
async
function
PolicyAndLaws
()
{
const
{
posts
,
sectionLink
}
=
await
fetchPolicyAndLawsPosts
();
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
posts
[
index
]
??
null
);
return
(
<
section
className=
"flex flex-1 flex-col"
>
...
...
@@ -76,5 +205,3 @@ function PolicyAndLaws() {
</
section
>
);
}
export
default
PolicyAndLaws
;
src/app/(main)/(home)/components/video-and-patners/index.tsx
deleted
100644 → 0
View file @
d3f4f2a6
"use client"
;
import
Image
from
"next/image"
;
import
partnerImages
from
"@/constants/partnerImages"
;
import
{
ChevronRight
,
Play
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
{
useGetOrganizations
}
from
"@/api/vcci-hcm/endpoints/organizations"
;
import
type
{
Organization
}
from
"@/api/vcci-hcm/models"
;
import
{
useGetApiV10Video
}
from
"@/api/vcci-news/endpoints/video"
;
import
type
{
Video
}
from
"@/api/vcci-news/models/video"
;
import
{
MOCK_PARTNERS_RESPONSE
}
from
"@/mockdata/bff-fallback"
;
import
{
getVideoThumbnail
,
normalizeVideoUrl
}
from
"@/lib/utils/video"
;
import
{
Autoplay
}
from
"swiper/modules"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
"swiper/css"
;
const
VCCI_HCM_SITE_URL
=
"https://vccihcm.vn"
;
const
resolvePartnerImage
=
(
avatar
:
string
|
null
|
undefined
,
index
:
number
)
=>
{
if
(
avatar
?.
startsWith
(
"http://"
)
||
avatar
?.
startsWith
(
"https://"
))
{
return
avatar
;
}
if
(
avatar
?.
startsWith
(
"/"
))
{
return
`
${
VCCI_HCM_SITE_URL
}${
avatar
}
`
;
}
return
partnerImages
[
index
%
partnerImages
.
length
]
??
"/img-error.png"
;
};
const
MOCK_PARTNER_ROWS
=
MOCK_PARTNERS_RESPONSE
.
responseData
.
rows
as
unknown
as
Organization
[];
type
ClientVideoItem
=
Video
&
{
thumbnail
:
string
;
watchUrl
:
string
;
};
const
toClientVideo
=
(
video
:
Video
):
ClientVideoItem
=>
({
...
video
,
thumbnail
:
getVideoThumbnail
(
video
.
url
),
watchUrl
:
normalizeVideoUrl
(
video
.
url
),
});
const
renderPartnerContent
=
(
partners
:
Organization
[])
=>
{
if
(
partners
.
length
>
0
)
{
return
(
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4200
,
disableOnInteraction
:
false
}
}
observer
observeParents
updateOnWindowResize
slidesPerView=
"auto"
spaceBetween=
{
16
}
className=
"w-full"
>
{
partners
.
map
((
partner
,
index
)
=>
(
<
SwiperSlide
key=
{
partner
.
id
}
className=
"!h-auto !w-full sm:!w-[calc(50%-8px)] xl:!w-[calc(33.333%-10.67px)]"
>
{
partner
.
website
?
(
<
a
href=
{
partner
.
website
}
target=
"_blank"
rel=
"noreferrer"
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]"
>
<
Image
src=
{
resolvePartnerImage
(
partner
.
avatar
,
index
)
}
alt=
{
partner
.
name
??
""
}
width=
{
140
}
height=
{
72
}
className=
"max-h-full w-full object-contain"
/>
</
div
>
</
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]"
>
<
Image
src=
{
resolvePartnerImage
(
partner
.
avatar
,
index
)
}
alt=
{
partner
.
name
??
""
}
width=
{
140
}
height=
{
72
}
className=
"max-h-full w-full object-contain"
/>
</
div
>
)
}
</
SwiperSlide
>
))
}
</
Swiper
>
);
}
return
(
<
div
className=
"rounded-[16px] border border-[#edf1f7] bg-white px-5 py-10 text-center text-sm text-gray-500"
>
Chưa có thông tin.
</
div
>
);
};
function
VideoAndPartners
()
{
const
{
data
:
videosData
,
isLoading
:
videosLoading
}
=
useGetApiV10Video
(
{
page
:
1
,
pageSize
:
2
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
},
{
query
:
{
staleTime
:
60
*
1000
,
select
:
(
response
)
=>
{
const
rows
=
(
response
?.
responseData
?.
rows
??
[])
as
unknown
as
Video
[];
return
rows
.
map
(
toClientVideo
);
},
},
},
);
const
{
data
:
partnersResponse
}
=
useGetOrganizations
<
Organization
[]
|
undefined
>
(
{
filters
:
"type==SPONSOR"
,
pageSize
:
"12"
,
sortField
:
"sort_order"
,
sortOrder
:
"ASC"
,
},
{
query
:
{
staleTime
:
60
*
1000
,
select
:
(
response
)
=>
{
const
rows
=
(
response
as
any
)?.
responseData
?.
rows
??
[];
return
rows
.
length
>
0
?
rows
:
MOCK_PARTNER_ROWS
;
},
},
},
);
const
videos
=
videosData
??
[];
const
partners
=
(
partnersResponse
??
MOCK_PARTNER_ROWS
).
slice
(
0
,
12
);
const
displayPartners
=
partners
;
return
(
<
section
className=
"flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch"
>
<
div
className=
"flex-1"
>
<
div
className=
"mb-5 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Video
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
"/video"
className=
"pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
videosLoading
?
(
Array
.
from
({
length
:
2
}).
map
((
_
,
index
)
=>
(
<
div
key=
{
`video-loading-${index}`
}
className=
"h-[202px] animate-pulse rounded-[16px] bg-[#edf1f7]"
/>
))
)
:
videos
.
length
?
(
videos
.
map
((
video
)
=>
(
<
a
key=
{
video
.
id
}
href=
{
video
.
watchUrl
}
target=
"_blank"
rel=
"noreferrer"
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"
>
<
Image
src=
{
video
.
thumbnail
}
alt=
{
video
.
name
}
width=
{
640
}
height=
{
440
}
className=
"h-full w-full object-cover"
/>
<
div
className=
"absolute inset-0 bg-black/18"
/>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
span
className=
"flex h-12 w-12 items-center justify-center rounded-full bg-white/92 text-[#2a4ea3] shadow-[0_10px_26px_rgba(0,0,0,0.18)]"
>
<
Play
className=
"ml-1 h-5 w-5 fill-current"
/>
</
span
>
</
div
>
</
div
>
<
div
className=
"px-4 py-2.5"
>
<
p
className=
"line-clamp-2 text-[14px] font-semibold leading-[1.32] text-[#264798] md:text-[15px]"
>
{
video
.
name
}
</
p
>
</
div
>
</
a
>
))
)
:
(
<
div
className=
"rounded-[16px] border border-[#e5ebf4] bg-white px-5 py-10 text-center text-sm text-gray-500 md:col-span-2"
>
Chưa có video nào.
</
div
>
)
}
</
div
>
</
div
>
<
aside
className=
"flex w-full flex-col xl:w-[43%]"
>
<
div
className=
"mb-5 flex items-start justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Đối tác
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
</
div
>
{
renderPartnerContent
(
displayPartners
)
}
</
aside
>
</
section
>
);
}
export
default
VideoAndPartners
;
src/app/(main)/(home)/components/videos/index.tsx
0 → 100644
View file @
ef2f7367
import
{
getApiV10Video
}
from
"@/api/vcci-news/endpoints/video"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
type
{
Video
}
from
"@/api/vcci-news/models/video"
;
import
{
getVideoThumbnail
,
normalizeVideoUrl
}
from
"@/lib/utils/video"
;
import
{
ChevronRight
,
Play
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
const
PAGE_SIZE
=
2
;
const
SECTION_PARAMS
=
{
page
:
1
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
}
as
const
;
type
VideoItem
=
{
id
:
string
;
name
:
string
;
thumbnail
:
string
;
watchUrl
:
string
;
};
const
toVideoItem
=
(
video
:
Video
):
VideoItem
=>
({
id
:
String
(
video
.
id
??
""
),
name
:
String
(
video
.
name
??
""
),
thumbnail
:
getVideoThumbnail
(
String
(
video
.
url
??
""
)),
watchUrl
:
normalizeVideoUrl
(
String
(
video
.
url
??
""
)),
});
const
fetchVideos
=
async
():
Promise
<
VideoItem
[]
>
=>
{
try
{
const
response
=
await
getApiV10Video
(
SECTION_PARAMS
);
const
rows
=
(
response
as
PagedResult
<
Video
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
rows
.
map
(
toVideoItem
);
}
catch
(
error
)
{
console
.
warn
(
"[Videos] CMS unavailable, returning empty list"
,
error
);
return
[];
}
};
export
function
VideosSkeleton
()
{
return
(
<
div
className=
"flex-1"
>
<
div
className=
"mb-5 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Video
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
"/video"
className=
"pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
Array
.
from
({
length
:
2
},
(
_
,
index
)
=>
(
<
div
key=
{
`video-skeleton-${index}`
}
className=
"h-[202px] animate-pulse rounded-[16px] bg-[#edf1f7]"
/>
))
}
</
div
>
</
div
>
);
}
export
async
function
Videos
()
{
const
videos
=
await
fetchVideos
();
return
(
<
div
className=
"flex-1"
>
<
div
className=
"mb-5 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
Video
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[40px] rounded-full bg-[#f7b500]"
/>
</
div
>
<
Link
href=
"/video"
className=
"pt-1 text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
<
ChevronRight
className=
"h-5 w-5"
/>
</
Link
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
videos
.
length
?
(
videos
.
map
((
video
)
=>
(
<
a
key=
{
video
.
id
}
href=
{
video
.
watchUrl
}
target=
"_blank"
rel=
"noreferrer"
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"
>
<
Image
src=
{
video
.
thumbnail
}
alt=
{
video
.
name
}
width=
{
640
}
height=
{
440
}
className=
"h-full w-full object-cover"
/>
<
div
className=
"absolute inset-0 bg-black/18"
/>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
span
className=
"flex h-12 w-12 items-center justify-center rounded-full bg-white/92 text-[#2a4ea3] shadow-[0_10px_26px_rgba(0,0,0,0.18)]"
>
<
Play
className=
"ml-1 h-5 w-5 fill-current"
/>
</
span
>
</
div
>
</
div
>
<
div
className=
"px-4 py-2.5"
>
<
p
className=
"line-clamp-2 text-[14px] font-semibold leading-[1.32] text-[#264798] md:text-[15px]"
>
{
video
.
name
}
</
p
>
</
div
>
</
a
>
))
)
:
(
<
div
className=
"rounded-[16px] border border-[#e5ebf4] bg-white px-5 py-10 text-center text-sm text-gray-500 md:col-span-2"
>
Chưa có video nào.
</
div
>
)
}
</
div
>
</
div
>
);
}
src/app/(main)/(home)/lib/use-advertisements.ts
deleted
100644 → 0
View file @
d3f4f2a6
"use client"
;
import
{
useGetApiV10AdvertisementPublic
}
from
"@/api/vcci-news/endpoints/advertisement"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
import
type
{
GetApiV10AdvertisementPublicType
}
from
"@/api/vcci-news/models/getApiV10AdvertisementPublicType"
;
/**
* Hook đọc danh sách quảng cáo active theo loại từ API backend (public, không cần auth).
* Backend đã lọc status=ACTIVE và sort theo sort_order ASC.
* Mỗi record có `file` (path, mime) — dùng `links.resolveImageUrl(file.path)` để lấy URL ảnh.
*
* @param type - "square" | "horizontal"
* @param limit - số lượng records tối đa (mặc định 20 cho square, 1 cho horizontal)
*/
export
function
useAdvertisements
(
type
:
"square"
|
"horizontal"
,
limit
?:
number
,
):
Advertisement
[]
{
const
effectiveLimit
=
limit
??
(
type
===
"horizontal"
?
1
:
5
);
const
{
data
}
=
useGetApiV10AdvertisementPublic
({
type
:
type
as
GetApiV10AdvertisementPublicType
,
limit
:
effectiveLimit
,
});
// Backend trả về { responseData: [...] }
return
(
(
data
as
unknown
as
{
responseData
?:
Advertisement
[]
}
|
undefined
)?.
responseData
??
[]
);
}
src/app/(main)/(home)/lib/use-home-posts.ts
deleted
100644 → 0
View file @
d3f4f2a6
"use client"
;
import
*
as
React
from
"react"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
dayjs
from
"dayjs"
;
import
{
getApiV10Post
,
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
Links
from
"@/links"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
type
RawHomeCategory
=
{
id
?:
string
|
null
;
name
?:
string
|
null
;
slug
?:
string
|
null
;
url
?:
string
|
null
;
type
?:
string
|
null
;
};
type
RawHomeThumbnail
=
{
path
?:
string
|
null
;
original
?:
string
|
null
;
};
type
RawHomePost
=
{
id
?:
string
|
null
;
title
?:
string
|
null
;
slug
?:
string
|
null
;
summary
?:
string
|
null
;
content
?:
string
|
null
;
release_at
?:
string
|
null
;
published_at
?:
string
|
null
;
created_at
?:
string
|
null
;
started_at
?:
string
|
null
;
ended_at
?:
string
|
null
;
registration_deadline
?:
string
|
null
;
location
?:
string
|
null
;
participation_fee
?:
string
|
null
;
expired_at
?:
string
|
null
;
is_featured
?:
boolean
|
null
;
is_hidden
?:
boolean
|
null
;
is_active
?:
boolean
|
null
;
status
?:
string
|
null
;
type
?:
string
|
null
;
event_dates
?:
string
[]
|
null
;
categories
?:
RawHomeCategory
[]
|
null
;
thumbnail
?:
RawHomeThumbnail
|
null
;
content_structure
?:
{
post_content
?:
Array
<
{
content
?:
string
|
null
;
}
>
|
null
;
}
|
null
;
};
type
HomeEnvelope
<
T
>
=
{
responseData
?:
T
;
};
type
HomePagedResult
<
T
>
=
{
rows
?:
T
[];
};
export
type
HomePostCategory
=
{
id
:
string
;
name
:
string
;
slug
:
string
;
url
:
string
;
type
:
string
;
};
export
type
HomePostItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
summary
:
string
;
contentText
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
startedAt
:
string
;
endedAt
:
string
;
registrationDeadline
:
string
;
location
:
string
;
participationFee
:
string
;
expiredAt
:
string
;
isFeatured
:
boolean
;
isHidden
:
boolean
;
isActive
:
boolean
;
status
:
string
;
type
:
string
;
eventDates
?:
string
[];
categories
:
HomePostCategory
[];
thumbnail
:
{
url
:
string
;
alt
:
string
;
}
|
null
;
};
const
HOME_POSTS_QUERY_KEY
=
[
"home-page-posts"
,
"featured-page-size-3"
]
as
const
;
const
HOME_CATEGORY_NAMES
=
{
tinVcci
:
"Tin VCCI"
,
tinKinhTe
:
"Tin Kinh tế"
,
chuyenDe
:
"Chuyên đề"
,
suKien
:
"Sự kiện"
,
daoTao
:
"Đào tạo"
,
coHoiKinhDoanh
:
"Cơ hội kinh doanh"
,
chinhSachPhapLuat
:
"Thông tin Chính sách và Pháp luật"
,
ketNoiHoiVien
:
"Kết nối hội viên"
,
}
as
const
;
const
normalizeText
=
(
value
?:
string
|
null
)
=>
value
?.
trim
().
toLowerCase
()
??
""
;
const
normalizeSearchText
=
(
value
?:
string
|
null
)
=>
normalizeText
(
value
)
.
normalize
(
"NFD"
)
.
replace
(
/
[\u
0300-
\u
036f
]
/g
,
""
)
.
replace
(
/đ/g
,
"d"
)
.
replace
(
/&/g
,
" va "
)
.
replace
(
/
[^
a-z0-9
]
+/g
,
" "
)
.
trim
()
.
replace
(
/
\s
+/g
,
" "
);
const
normalizeSlug
=
(
value
?:
string
|
null
)
=>
normalizeSearchText
(
value
).
replace
(
/
\s
+/g
,
"-"
);
const
HOME_CATEGORY_ALIASES
=
{
tinVcci
:
[
"Tin VCCI"
,
"tin-vcci"
],
tinKinhTe
:
[
"Tin Kinh tế"
,
"tin-kinh-te"
],
chuyenDe
:
[
"Chuyên đề"
,
"chuyen-de"
],
suKien
:
[
"Sự kiện"
,
"su-kien"
],
daoTao
:
[
"Đào tạo"
,
"dao-tao"
],
coHoiKinhDoanh
:
[
"Cơ hội kinh doanh"
,
"co-hoi-kinh-doanh"
],
chinhSachPhapLuat
:
[
"Thông tin Chính sách và Pháp luật"
,
"Chính sách và Pháp luật"
,
"Chính sách & Pháp luật"
,
"Chính sách Pháp luật"
,
"thong-tin-chinh-sach-va-phap-luat"
,
"chinh-sach-va-phap-luat"
,
"chinh-sach-phap-luat"
,
],
ketNoiHoiVien
:
[
"Kết nối hội viên"
,
"ket-noi-hoi-vien"
],
}
as
const
;
const
HOME_CATEGORY_IDS
=
{
tinVcci
:
"b89b2ba6-a699-47cb-87e4-0643aea549a9"
,
tinKinhTe
:
"755106b6-1aca-47dc-9a9c-d434736c33a1"
,
chuyenDe
:
"8e7090e5-bfc3-4128-81a5-37ec78c33bad"
,
suKien
:
"b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a"
,
daoTao
:
"36df7021-9a74-43d6-9084-0d5ed347b7f4"
,
coHoiKinhDoanh
:
"0a460499-89c1-4f52-8592-1fb7bb69c4a2"
,
ketNoiHoiVien
:
"a37b8a02-e8b3-42ce-9225-6dae460fed99"
,
chinhSachPhapLuat
:
"cc448be9-b9ea-46a8-aa7b-0584803330e8"
,
lienKetNhanh
:
"d7f05384-b1b4-428e-b9b3-37e0e1b0cecd"
,
}
as
const
;
const
normalizeLink
=
(
value
?:
string
|
null
,
fallback
=
"/"
)
=>
{
const
trimmed
=
value
?.
trim
();
if
(
!
trimmed
)
return
fallback
;
if
(
trimmed
.
startsWith
(
"http://"
)
||
trimmed
.
startsWith
(
"https://"
))
return
trimmed
;
if
(
trimmed
.
startsWith
(
"/"
))
return
trimmed
;
return
`/
${
trimmed
}
`
;
};
const
buildPostLink
=
(
path
?:
string
|
null
,
id
?:
string
|
null
,
fallback
=
"#"
)
=>
{
const
normalizedPath
=
normalizeLink
(
path
,
fallback
);
const
trimmedId
=
id
?.
trim
()
??
""
;
if
(
!
trimmedId
||
normalizedPath
===
"#"
)
return
normalizedPath
;
const
params
=
new
URLSearchParams
({
id
:
trimmedId
});
return
`
${
normalizedPath
}
?
${
params
.
toString
()}
`
;
};
const
resolveAssetUrl
=
(
value
?:
string
|
null
)
=>
{
const
trimmed
=
value
?.
trim
();
if
(
!
trimmed
)
return
"/thumbnail.png"
;
return
Links
.
resolveImageUrl
(
trimmed
);
};
const
sortByPublishedDesc
=
(
items
:
HomePostItem
[])
=>
[...
items
].
sort
((
left
,
right
)
=>
{
const
leftTime
=
new
Date
(
left
.
publishedAt
||
left
.
createdAt
).
getTime
();
const
rightTime
=
new
Date
(
right
.
publishedAt
||
right
.
createdAt
).
getTime
();
return
rightTime
-
leftTime
;
});
const
sortByEventStartAsc
=
(
items
:
HomePostItem
[])
=>
[...
items
].
sort
((
left
,
right
)
=>
{
const
leftTime
=
new
Date
(
left
.
startedAt
||
left
.
publishedAt
||
left
.
createdAt
).
getTime
();
const
rightTime
=
new
Date
(
right
.
startedAt
||
right
.
publishedAt
||
right
.
createdAt
).
getTime
();
return
leftTime
-
rightTime
;
});
const
uniquePosts
=
(
items
:
HomePostItem
[])
=>
{
const
seen
=
new
Set
<
string
>
();
return
items
.
filter
((
item
)
=>
{
if
(
!
item
.
id
||
seen
.
has
(
item
.
id
))
return
false
;
seen
.
add
(
item
.
id
);
return
true
;
});
};
const
matchesCategoryName
=
(
item
:
HomePostItem
,
categoryName
:
string
)
=>
{
const
normalizedTarget
=
normalizeText
(
categoryName
);
return
item
.
categories
.
some
(
(
category
)
=>
normalizeText
(
category
.
name
)
===
normalizedTarget
,
);
};
const
matchesCategoryAliases
=
(
item
:
HomePostItem
,
aliases
:
readonly
string
[],
)
=>
{
const
aliasKeys
=
new
Set
(
aliases
.
map
(
normalizeSearchText
));
const
aliasSlugs
=
new
Set
(
aliases
.
map
(
normalizeSlug
));
return
item
.
categories
.
some
((
category
)
=>
{
const
categoryNameKey
=
normalizeSearchText
(
category
.
name
);
const
categorySlugKey
=
normalizeSlug
(
category
.
slug
||
category
.
name
);
const
categoryUrlKey
=
normalizeSlug
(
category
.
url
);
const
categoryUrlSegments
=
category
.
url
.
split
(
"/"
)
.
map
((
segment
)
=>
normalizeSlug
(
segment
))
.
filter
(
Boolean
);
return
(
aliasKeys
.
has
(
categoryNameKey
)
||
aliasSlugs
.
has
(
categorySlugKey
)
||
categoryUrlSegments
.
some
((
segment
)
=>
aliasSlugs
.
has
(
segment
))
||
Array
.
from
(
aliasSlugs
).
some
((
slug
)
=>
categoryUrlKey
.
endsWith
(
slug
))
);
});
};
const
isVisibleNewsPost
=
(
item
:
HomePostItem
)
=>
{
if
(
item
.
type
&&
item
.
type
!==
"news"
)
return
false
;
if
(
item
.
isHidden
)
return
false
;
if
(
!
item
.
isActive
)
return
false
;
return
true
;
};
const
hasCategoryId
=
(
item
:
HomePostItem
,
categoryId
:
string
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
id
===
categoryId
);
async
function
fetchHomePostRows
(
params
:
{
page
?:
number
;
pageSize
?:
number
;
sortField
?:
string
;
sortOrder
?:
string
;
filters
?:
string
;
})
{
const
response
=
await
getApiV10Post
({
page
:
params
.
page
??
1
,
pageSize
:
params
.
pageSize
??
10
,
sortField
:
params
.
sortField
??
"release_at"
,
sortOrder
:
(
params
.
sortOrder
??
"desc"
)
as
"asc"
|
"desc"
,
filters
:
params
.
filters
,
});
return
((
response
.
responseData
?.
rows
??
[])
as
unknown
as
RawHomePost
[]);
}
function
createCategoryPostsParams
(
categoryId
:
string
,
pageSize
:
string
)
{
return
{
page
:
1
,
pageSize
:
Number
(
pageSize
),
sortField
:
"release_at"
,
sortOrder
:
"desc"
as
const
,
filters
:
[
`category.id==
${
categoryId
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
};
}
function
createMultiCategoryPostsParams
(
categoryIds
:
string
[],
pageSize
:
string
)
{
return
{
page
:
1
,
pageSize
:
Number
(
pageSize
),
sortField
:
"release_at"
,
sortOrder
:
"desc"
as
const
,
filters
:
[
`category.id==(
${
categoryIds
.
join
(
"|"
)}
)`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
};
}
function
createEventCalendarParams
(
currentMonth
:
Date
)
{
return
{
page
:
1
,
pageSize
:
100
,
sortField
:
"started_at"
,
sortOrder
:
"asc"
as
const
,
filters
:
[
`category.id==(
${
HOME_CATEGORY_IDS
.
suKien
}
|
${
HOME_CATEGORY_IDS
.
daoTao
}
)`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
};
}
async
function
fetchHomePosts
()
{
// Trả về mock ngay khi CMS/BE gặp sự cố — website vẫn hiển thị được nội dung cơ bản.
try
{
return
await
fetchHomePostsFromApi
();
}
catch
(
error
)
{
// eslint-disable-next-line no-console
console
.
warn
(
"[useHomePosts] CMS unavailable, falling back to mock data"
,
error
);
return
MOCK_HOME_POSTS
;
}
}
async
function
fetchHomePostsFromApi
()
{
const
featuredParams
=
{
page
:
1
,
pageSize
:
3
,
sortField
:
"release_at"
,
sortOrder
:
"desc"
as
const
,
filters
:
[
"is_featured==true"
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
].
join
(
","
),
};
const
tinVcciParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
tinVcci
,
"6"
);
const
tinKinhTeParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
tinKinhTe
,
"6"
);
const
chuyenDeParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
chuyenDe
,
"6"
);
const
eventParams
=
createMultiCategoryPostsParams
(
[
HOME_CATEGORY_IDS
.
suKien
,
HOME_CATEGORY_IDS
.
daoTao
],
"5"
);
const
policyParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
chinhSachPhapLuat
,
"6"
);
const
quickLinksParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
lienKetNhanh
,
"6"
);
const
trainingParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
daoTao
,
"10"
);
const
businessParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
coHoiKinhDoanh
,
"10"
);
const
memberConnectionParams
=
createCategoryPostsParams
(
HOME_CATEGORY_IDS
.
ketNoiHoiVien
,
"10"
);
const
[
featuredRows
,
tinVcciRows
,
tinKinhTeRows
,
chuyenDeRows
,
policyRows
,
eventRows
,
quickLinkRows
,
trainingRows
,
businessRows
,
memberConnectionRows
,
]
=
await
Promise
.
all
([
fetchHomePostRows
(
featuredParams
),
fetchHomePostRows
(
tinVcciParams
),
fetchHomePostRows
(
tinKinhTeParams
),
fetchHomePostRows
(
chuyenDeParams
),
fetchHomePostRows
(
policyParams
),
fetchHomePostRows
(
eventParams
),
fetchHomePostRows
(
quickLinksParams
),
fetchHomePostRows
(
trainingParams
),
fetchHomePostRows
(
businessParams
),
fetchHomePostRows
(
memberConnectionParams
),
]);
const
rows
=
[
...
featuredRows
,
...
tinVcciRows
,
...
tinKinhTeRows
,
...
chuyenDeRows
,
...
policyRows
,
...
eventRows
,
...
trainingRows
,
...
quickLinkRows
,
...
businessRows
,
...
memberConnectionRows
,
];
return
rows
.
map
<
HomePostItem
>
((
item
)
=>
{
const
categories
=
(
item
.
categories
??
[])
.
filter
((
category
)
=>
category
?.
id
&&
category
?.
name
)
.
map
((
category
)
=>
({
id
:
String
(
category
.
id
),
name
:
String
(
category
.
name
),
slug
:
String
(
category
.
slug
??
""
),
url
:
normalizeLink
(
category
.
url
,
"#"
),
type
:
String
(
category
.
type
??
""
),
}));
const
thumbnailPath
=
item
.
thumbnail
?.
path
??
item
.
thumbnail
?.
original
??
null
;
const
title
=
String
(
item
.
title
??
""
).
trim
();
const
externalLink
=
buildPostLink
(
item
.
slug
?
`/
${
item
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
item
.
id
?
String
(
item
.
id
)
:
""
,
"#"
,
);
return
{
id
:
String
(
item
.
id
??
""
),
title
,
externalLink
,
summary
:
String
(
item
.
summary
??
item
.
content
??
""
),
contentText
:
String
(
item
.
content_structure
?.
post_content
?.[
0
]?.
content
??
item
.
summary
??
item
.
content
??
""
),
createdAt
:
String
(
item
.
created_at
??
""
),
publishedAt
:
String
(
item
.
published_at
??
item
.
release_at
??
item
.
created_at
??
""
),
startedAt
:
String
(
item
.
started_at
??
""
),
endedAt
:
String
(
item
.
ended_at
??
""
),
registrationDeadline
:
String
(
item
.
registration_deadline
??
""
),
location
:
String
(
item
.
location
??
""
),
participationFee
:
String
(
item
.
participation_fee
??
""
),
expiredAt
:
String
(
item
.
expired_at
??
""
),
isFeatured
:
Boolean
(
item
.
is_featured
),
isHidden
:
Boolean
(
item
.
is_hidden
),
isActive
:
item
.
is_active
!==
false
,
status
:
String
(
item
.
status
??
""
),
type
:
String
(
item
.
type
??
""
),
eventDates
:
Array
.
isArray
(
item
.
event_dates
)
?
item
.
event_dates
.
filter
((
d
):
d
is
string
=>
typeof
d
===
"string"
)
:
[],
categories
,
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
,
}
:
null
,
};
})
.
filter
((
item
)
=>
item
.
id
&&
item
.
title
);
}
export
function
useHomePosts
()
{
const
query
=
useQuery
({
queryKey
:
HOME_POSTS_QUERY_KEY
,
queryFn
:
fetchHomePosts
,
staleTime
:
5
*
60
*
1000
,
});
const
allPosts
=
React
.
useMemo
(
()
=>
uniquePosts
(
query
.
data
??
[]),
[
query
.
data
],
);
const
posts
=
React
.
useMemo
(
()
=>
allPosts
.
filter
(
isVisibleNewsPost
),
[
allPosts
],
);
const
categoryLinks
=
React
.
useMemo
(()
=>
{
const
entries
=
posts
.
flatMap
((
item
)
=>
item
.
categories
);
const
map
=
new
Map
<
string
,
string
>
();
entries
.
forEach
((
category
)
=>
{
const
key
=
normalizeText
(
category
.
name
);
if
(
!
key
||
map
.
has
(
key
)
||
!
category
.
url
||
category
.
url
===
"#"
)
return
;
map
.
set
(
key
,
category
.
url
);
});
return
map
;
},
[
posts
]);
const
tinVcciPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
tinVcci
),
),
),
[
posts
],
);
const
tinKinhTePosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
tinKinhTe
),
),
),
[
posts
],
);
const
chuyenDePosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
chuyenDe
),
),
),
[
posts
],
);
const
featuredPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
allPosts
.
filter
((
item
)
=>
item
.
isFeatured
&&
!
item
.
isHidden
),
),
[
allPosts
],
);
const
eventPosts
=
React
.
useMemo
(
()
=>
sortByEventStartAsc
(
posts
.
filter
(
(
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
suKien
),
),
),
[
posts
],
);
const
eventCalendarPosts
=
React
.
useMemo
(
()
=>
sortByEventStartAsc
(
posts
.
filter
(
(
item
)
=>
Boolean
(
item
.
registrationDeadline
)
&&
(
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
suKien
)
||
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
daoTao
)),
),
),
[
posts
],
);
const
businessPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
coHoiKinhDoanh
),
),
),
[
posts
],
);
const
policyPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
chinhSachPhapLuat
),
),
),
[
posts
],
);
const
memberConnectionPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryAliases
(
item
,
HOME_CATEGORY_ALIASES
.
ketNoiHoiVien
),
),
),
[
posts
],
);
const
quickLinkPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
hasCategoryId
(
item
,
HOME_CATEGORY_IDS
.
lienKetNhanh
)),
),
[
posts
],
);
const
allNewsPosts
=
React
.
useMemo
(
()
=>
uniquePosts
([...
tinVcciPosts
,
...
tinKinhTePosts
,
...
chuyenDePosts
]),
[
chuyenDePosts
,
tinKinhTePosts
,
tinVcciPosts
],
);
return
{
...
query
,
allPosts
,
posts
,
featuredPosts
,
eventPosts
,
eventCalendarPosts
,
businessPosts
,
policyPosts
,
memberConnectionPosts
,
quickLinkPosts
,
newsTabs
:
{
all
:
allNewsPosts
,
tinVcci
:
tinVcciPosts
,
tinKinhTe
:
tinKinhTePosts
,
chuyenDe
:
chuyenDePosts
,
},
categoryLinks
,
categoryNames
:
HOME_CATEGORY_NAMES
,
};
}
export
function
useEventCalendarPosts
(
currentMonth
:
Date
)
{
const
params
=
createEventCalendarParams
(
currentMonth
);
const
monthStart
=
new
Date
(
currentMonth
.
getFullYear
(),
currentMonth
.
getMonth
(),
1
);
const
monthEnd
=
new
Date
(
currentMonth
.
getFullYear
(),
currentMonth
.
getMonth
()
+
1
,
0
,
23
,
59
,
59
,
999
);
const
query
=
useGetApiV10Post
<
HomePostItem
[]
>
(
params
,
{
query
:
{
staleTime
:
5
*
60
*
1000
,
select
:
(
response
):
HomePostItem
[]
=>
{
const
mappedPosts
=
((
response
?.
responseData
?.
rows
??
[])
as
unknown
as
RawHomePost
[]).
map
((
item
)
=>
{
const
categories
=
(
item
.
categories
??
[])
.
filter
((
category
)
=>
category
?.
id
&&
category
?.
name
)
.
map
((
category
)
=>
({
id
:
String
(
category
.
id
),
name
:
String
(
category
.
name
),
slug
:
String
(
category
.
slug
??
""
),
url
:
normalizeLink
(
category
.
url
,
"#"
),
type
:
String
(
category
.
type
??
""
),
}));
const
thumbnailPath
=
item
.
thumbnail
?.
path
??
item
.
thumbnail
?.
original
??
null
;
const
title
=
String
(
item
.
title
??
""
).
trim
();
const
externalLink
=
buildPostLink
(
item
.
slug
?
`/
${
item
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
item
.
id
?
String
(
item
.
id
)
:
""
,
"#"
,
);
return
{
id
:
String
(
item
.
id
??
""
),
title
,
externalLink
,
summary
:
String
(
item
.
summary
??
item
.
content
??
""
),
contentText
:
String
(
item
.
content_structure
?.
post_content
?.[
0
]?.
content
??
item
.
summary
??
item
.
content
??
""
),
createdAt
:
String
(
item
.
created_at
??
""
),
publishedAt
:
String
(
item
.
published_at
??
item
.
release_at
??
item
.
created_at
??
""
),
startedAt
:
String
(
item
.
started_at
??
""
),
endedAt
:
String
(
item
.
ended_at
??
""
),
registrationDeadline
:
String
(
item
.
registration_deadline
??
""
),
location
:
String
(
item
.
location
??
""
),
participationFee
:
String
(
item
.
participation_fee
??
""
),
expiredAt
:
String
(
item
.
expired_at
??
""
),
isFeatured
:
Boolean
(
item
.
is_featured
),
isHidden
:
Boolean
(
item
.
is_hidden
),
isActive
:
item
.
is_active
!==
false
,
status
:
String
(
item
.
status
??
""
),
type
:
String
(
item
.
type
??
""
),
eventDates
:
Array
.
isArray
(
item
.
event_dates
)
?
item
.
event_dates
.
filter
((
d
):
d
is
string
=>
typeof
d
===
"string"
)
:
[],
categories
,
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
,
}
:
null
,
}
satisfies
HomePostItem
;
});
return
mappedPosts
.
filter
((
item
)
=>
{
const
startedAt
=
item
.
startedAt
?
dayjs
(
item
.
startedAt
)
:
null
;
const
endedAt
=
item
.
endedAt
?
dayjs
(
item
.
endedAt
)
:
null
;
const
registrationDeadline
=
item
.
registrationDeadline
?
dayjs
(
item
.
registrationDeadline
)
:
null
;
const
eventDates
=
(
item
.
eventDates
??
[]).
map
((
d
)
=>
dayjs
(
d
)).
filter
((
d
)
=>
d
.
isValid
());
if
(
!
startedAt
&&
!
endedAt
&&
!
registrationDeadline
&&
eventDates
.
length
===
0
)
return
false
;
const
monthStartDay
=
dayjs
(
monthStart
);
const
monthEndDay
=
dayjs
(
monthEnd
);
const
hasDateInMonth
=
(
date
:
dayjs
.
Dayjs
|
null
):
boolean
=>
{
return
date
!==
null
&&
!
date
.
isBefore
(
monthStartDay
,
"day"
)
&&
!
date
.
isAfter
(
monthEndDay
,
"day"
);
};
if
(
eventDates
.
length
>
0
)
{
return
eventDates
.
some
((
d
)
=>
hasDateInMonth
(
d
));
}
const
eventStartDate
=
startedAt
||
registrationDeadline
;
const
eventEndDate
=
endedAt
||
registrationDeadline
||
startedAt
;
if
(
eventStartDate
&&
eventEndDate
)
{
return
!
eventStartDate
.
isAfter
(
monthEndDay
,
"day"
)
&&
!
eventEndDate
.
isBefore
(
monthStartDay
,
"day"
);
}
if
(
startedAt
&&
hasDateInMonth
(
startedAt
))
return
true
;
if
(
endedAt
&&
hasDateInMonth
(
endedAt
))
return
true
;
if
(
registrationDeadline
&&
hasDateInMonth
(
registrationDeadline
))
return
true
;
return
false
;
});
},
},
},
);
return
query
;
}
src/app/(main)/(home)/page.tsx
View file @
ef2f7367
import
FeaturedNews
from
"./components/featured-news"
;
import
Advertisements
from
"./components/quick-links"
;
import
HorizontalAdBanner
from
"./components/horizontal-ad-banner"
;
import
News
from
"./components/news"
;
import
Events
from
"./components/events"
;
import
BusinessOpportunities
from
"./components/business-opportunities"
;
import
PolicyAndLaws
from
"./components/policies-and-laws"
;
import
EventsCalendar
from
"./components/events-calendar"
;
import
Banner
from
"./components/banner"
;
import
Members
from
"./components/members"
;
import
VideoAndPartners
from
"./components/video-and-patners"
;
import
{
Suspense
}
from
"react"
;
import
{
FeaturedNews
,
FeaturedNewsSkeleton
}
from
"./components/featured-news"
;
import
{
Advertisements
}
from
"./components/advertisements"
;
import
{
HorizontalAdBanner
}
from
"./components/horizontal-ad-banner"
;
import
{
News
}
from
"./components/news"
;
import
{
Events
,
EventsSkeleton
}
from
"./components/events"
;
import
{
BusinessOpportunities
,
BusinessOpportunitiesSkeleton
}
from
"./components/business-opportunities"
;
import
{
PolicyAndLaws
,
PolicyAndLawsSkeleton
}
from
"./components/policies-and-laws"
;
import
{
EventsCalendar
}
from
"@/components/base/events-calendar"
;
import
{
Banner
}
from
"./components/banner"
;
import
{
FeaturedMembers
}
from
"./components/featured-members"
;
import
{
MemberConnection
,
MemberConnectionSkeleton
}
from
"./components/member-connection"
;
import
{
Videos
,
VideosSkeleton
}
from
"./components/videos"
;
import
{
Partners
}
from
"./components/partners"
;
export
default
function
HomePage
()
{
return
(
...
...
@@ -19,7 +22,9 @@ export default function HomePage() {
<
Banner
/>
{
/* contents */
}
<
div
className=
"container mx-auto px-3 sm:px-6 lg:px-10 space-y-6"
>
<
Suspense
fallback=
{
<
FeaturedNewsSkeleton
/>
}
>
<
FeaturedNews
/>
</
Suspense
>
<
section
className=
"flex flex-col xl:flex-row pb-8 gap-5 mb-0"
>
<
News
/>
...
...
@@ -29,7 +34,9 @@ export default function HomePage() {
<
HorizontalAdBanner
/>
<
section
className=
"flex flex-col gap-5 xl:flex-row xl:items-stretch"
>
<
Suspense
fallback=
{
<
EventsSkeleton
/>
}
>
<
Events
/>
</
Suspense
>
<
EventsCalendar
/>
</
section
>
...
...
@@ -37,16 +44,30 @@ export default function HomePage() {
<
div
className=
"flex flex-col flex-1"
>
<
section
className=
"flex flex-col xl:flex-row gap-5"
>
<
div
className=
"flex flex-col md:flex-row gap-5 pt-8 flex-1 order-2 xl:order-1"
>
<
Suspense
fallback=
{
<
BusinessOpportunitiesSkeleton
/>
}
>
<
BusinessOpportunities
/>
</
Suspense
>
<
Suspense
fallback=
{
<
PolicyAndLawsSkeleton
/>
}
>
<
PolicyAndLaws
/>
</
Suspense
>
</
div
>
<
Advertisements
count=
{
2
}
startIndex=
{
3
}
/>
</
section
>
</
div
>
</
div
>
<
Members
/>
<
VideoAndPartners
/>
<
section
className=
"flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"
>
<
FeaturedMembers
/>
<
Suspense
fallback=
{
<
MemberConnectionSkeleton
/>
}
>
<
MemberConnection
/>
</
Suspense
>
</
section
>
<
section
className=
"flex flex-col gap-6 pb-10 xl:flex-row xl:items-stretch"
>
<
Suspense
fallback=
{
<
VideosSkeleton
/>
}
>
<
Videos
/>
</
Suspense
>
<
Partners
/>
</
section
>
</
div
>
</
div
>
);
...
...
src/app/(main)/[...slug]/DynamicPageClient.tsx
View file @
ef2f7367
...
...
@@ -21,9 +21,6 @@ import Fees from "./static-pages/Fees";
import
Locations
from
"./static-pages/Locations"
;
import
Contact
from
"./static-pages/Contact"
;
import
MemberDirectory
from
"./static-pages/MemberDirectory"
;
import
Search
from
"./static-pages/Search"
;
import
SiteMap
from
"./static-pages/SiteMap"
;
import
Video
from
"./static-pages/Video"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
useGetApiV10Category
}
from
"@/api/vcci-news/endpoints/category"
;
import
type
{
DynamicCategoryRouteItem
}
from
"./templates/types"
;
...
...
@@ -66,7 +63,7 @@ export default function DynamicPageClient() {
"quy-trinh-tiep-nhan-ho-so-cap-gcn-va-xac-nhan-cttm"
,
"bieu-mau-gcn-va-noi-dung-khai-bao-gcn-cttm"
,
"phi-cap-gcn-va-xac-nhan-cttm"
,
"diem-cap-va-thoi-gian-cap-gcn-va-xac-nhan-cttm"
,
"thong-tin-lien-he"
,
"danh-ba-hoi-vien"
,
"search"
,
"site-map"
,
"video"
,
"danh-ba-hoi-vien"
,
].
includes
(
endingSlug
);
const
{
data
:
categoryData
,
isLoading
:
categoryLoading
}
=
useGetApiV10Category
({
...
...
@@ -289,27 +286,6 @@ export default function DynamicPageClient() {
allCategories=
{
allCategories
}
/>
);
case
"search"
:
return
(
<
Search
category=
{
matchedCategory
}
allCategories=
{
allCategories
}
/>
);
case
"site-map"
:
return
(
<
SiteMap
category=
{
matchedCategory
}
allCategories=
{
allCategories
}
/>
);
case
"video"
:
return
(
<
Video
category=
{
matchedCategory
}
allCategories=
{
allCategories
}
/>
);
}
if
(
detailPost
)
{
...
...
src/app/(main)/[...slug]/page.tsx
View file @
ef2f7367
...
...
@@ -18,7 +18,7 @@ const STATIC_PAGE_SLUGS = new Set([
"quy-trinh-tiep-nhan-ho-so-cap-gcn-va-xac-nhan-cttm"
,
"bieu-mau-gcn-va-noi-dung-khai-bao-gcn-cttm"
,
"phi-cap-gcn-va-xac-nhan-cttm"
,
"diem-cap-va-thoi-gian-cap-gcn-va-xac-nhan-cttm"
,
"thong-tin-lien-he"
,
"danh-ba-hoi-vien"
,
"search"
,
"site-map"
,
"video"
,
"danh-ba-hoi-vien"
,
]);
const
SITE_NAME
=
"VCCI HCM"
;
...
...
@@ -100,21 +100,6 @@ const STATIC_PAGE_METADATA: Record<string, { title: string; description: string
description
:
"Danh bạ hội viên VCCI-HCM: tra cứu thông tin doanh nghiệp hội viên và kết nối đối tác kinh doanh."
,
},
"search"
:
{
title
:
"Tìm kiếm"
,
description
:
"Tìm kiếm tin tức, bài viết và nội dung trên website VCCI-HCM theo từ khóa."
,
},
"site-map"
:
{
title
:
"Sơ đồ site"
,
description
:
"Sơ đồ tổng quan các chuyên mục và trang trên website VCCI-HCM, hỗ trợ điều hướng nhanh."
,
},
"video"
:
{
title
:
"Video"
,
description
:
"Thư viện video VCCI-HCM: các phóng sự, sự kiện, hội thảo và hoạt động xúc tiến thương mại."
,
},
};
/**
...
...
src/app/(main)/[...slug]/static-pages/AnPham.tsx
View file @
ef2f7367
...
...
@@ -8,7 +8,7 @@ import { Pagination } from "@/components/base/pagination";
import
Image
from
"next/image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
EventsCalendar
from
"@/app/(main)/(home)/components
/events-calendar"
;
import
{
EventsCalendar
}
from
"@/components/base
/events-calendar"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
...
...
src/app/(main)/[...slug]/static-pages/SiteMap.tsx
deleted
100644 → 0
View file @
d3f4f2a6
"use client"
;
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
Link
from
"next/link"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
}
from
"../templates/data"
;
import
type
{
DynamicCategoryRouteItem
}
from
"../templates/types"
;
import
{
getApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
type
SiteMapProps
=
{
category
:
DynamicCategoryRouteItem
|
null
;
allCategories
:
DynamicCategoryRouteItem
[];
};
export
default
function
SiteMap
({
category
,
allCategories
}:
SiteMapProps
)
{
const
[
categoriesData
,
setCategoriesData
]
=
useState
<
{
responseData
?:
HeaderCategoryTreeItem
[]
}
|
null
>
(
null
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
true
);
const
[
isError
,
setIsError
]
=
useState
(
false
);
useEffect
(()
=>
{
void
getApiV10CategoryTree
()
.
then
((
response
)
=>
{
setCategoriesData
(
response
as
{
responseData
?:
HeaderCategoryTreeItem
[]
});
setIsLoading
(
false
);
})
.
catch
(()
=>
{
setIsError
(
true
);
setIsLoading
(
false
);
});
},
[]);
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
if
(
isLoading
)
{
return
(
<>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
div
className=
"min-h-screen bg-gray-50 py-12 flex items-center justify-center"
>
<
div
className=
"text-[#063e8e] text-xl font-semibold"
>
Đang tải...
</
div
>
</
div
>
</>
);
}
if
(
isError
||
!
categoriesData
?.
responseData
)
{
return
(
<>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
div
className=
"min-h-screen bg-gray-50 py-12 flex items-center justify-center"
>
<
div
className=
"text-red-600 text-xl font-semibold"
>
Không thể tải dữ liệu
</
div
>
</
div
>
</>
);
}
const
sections
=
categoriesData
?.
responseData
??
[];
return
(
<>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
div
className=
"min-h-screen bg-gray-50 py-12"
>
<
div
className=
"container mx-auto px-4"
>
<
h1
className=
"text-3xl font-bold text-center mb-12 text-[#063e8e]"
>
SƠ ĐỒ TRANG WEB
</
h1
>
{
/* Sitemap Structure */
}
<
div
className=
"relative flex flex-col items-center"
>
{
/* Homepage - Top Level */
}
<
div
className=
"relative mb-20"
>
<
Link
href=
"/"
className=
"block bg-[#063e8e] text-white px-8 py-4 rounded-lg font-semibold text-center hover:bg-[#0a4fb5] transition shadow-lg min-w-[200px]"
>
TRANG CHỦ
</
Link
>
{
/* Vertical line from homepage down */
}
<
div
className=
"absolute left-[99px] -translate-x-1/2 top-full h-20 w-0.5 bg-gray-600"
></
div
>
</
div
>
{
/* Main Sections - Second Level */
}
<
div
className=
"relative w-full max-w-[1400px]"
>
{
/* Horizontal line connecting all sections */
}
<
div
className=
"absolute top-0 left-[6.3%] right-[6.3%] h-0.5 bg-gray-600 z-0"
></
div
>
<
div
className=
"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-6 relative pt-4"
>
{
sections
.
map
((
section
,
idx
)
=>
(
<
div
key=
{
section
.
id
}
className=
"relative flex flex-col items-center"
>
{
/* Vertical line from horizontal bar down to section */
}
<
div
className=
"absolute -top-4 left-1/2 -translate-x-1/2 h-4 w-0.5 bg-gray-600 z-10"
></
div
>
{
/* Section Box */
}
<
div
className=
"relative z-20"
>
<
Link
href=
{
section
.
url
||
"#"
}
className=
"flex bg-[#063e8e] text-white px-4 py-3 rounded-md font-medium text-center hover:bg-[#0a4fb5] transition shadow-md w-full text-sm min-h-20 items-center justify-center"
>
<
span
className=
"leading-tight"
>
{
section
.
name
.
toUpperCase
()
}
</
span
>
</
Link
>
{
/* Vertical line from section down to children */
}
{
section
.
children
&&
section
.
children
.
length
>
0
&&
(
<
div
className=
"absolute left-1/2 -translate-x-1/2 top-full h-6 w-0.5 bg-gray-600 z-10"
></
div
>
)
}
</
div
>
{
/* Children - Third Level */
}
{
section
.
children
&&
section
.
children
.
length
>
0
&&
(
<
div
className=
"mt-6 flex flex-col gap-3 w-full relative"
>
{
/* Vertical spine connecting all children */
}
<
div
className=
"absolute left-1/2 -translate-x-1/2 w-0.5 bg-gray-600"
style=
{
{
top
:
'-24px'
,
bottom
:
'0'
,
}
}
></
div
>
{
section
.
children
.
map
((
child
,
childIdx
)
=>
(
<
div
key=
{
child
.
id
}
className=
"relative"
>
{
/* Horizontal line from spine to child box */
}
<
div
className=
"absolute right-1/2 top-1/2 -translate-y-1/2 w-1/2 h-0.5 bg-gray-600"
></
div
>
<
Link
href=
{
child
.
url
||
"#"
}
className=
"block bg-gray-400 text-white px-3 py-2.5 rounded text-xs font-medium text-center hover:bg-gray-500 transition shadow-sm leading-tight relative z-10"
>
{
child
.
name
.
toUpperCase
()
}
</
Link
>
</
div
>
))
}
</
div
>
)
}
</
div
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
<
style
jsx
>
{
`
@media (max-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
`
}
</
style
>
</
div
>
</>
);
}
src/app/(main)/[...slug]/static-pages/ThuVienTaiLieu.tsx
View file @
ef2f7367
...
...
@@ -8,7 +8,7 @@ import { Pagination } from "@/components/base/pagination";
import
Image
from
"next/image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
EventsCalendar
from
"@/app/(main)/(home)/components
/events-calendar"
;
import
{
EventsCalendar
}
from
"@/components/base
/events-calendar"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
...
...
src/app/(main)/[...slug]/static-pages/Video.tsx
deleted
100644 → 0
View file @
d3f4f2a6
"use client"
;
import
{
Suspense
}
from
"react"
;
import
{
usePathname
,
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
{
Play
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
Spinner
}
from
"@/components/ui/spinner"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
}
from
"../templates/data"
;
import
type
{
DynamicCategoryRouteItem
}
from
"../templates/types"
;
import
{
useGetApiV10Video
}
from
"@/api/vcci-news/endpoints/video"
;
import
type
{
Video
}
from
"@/api/vcci-news/models/video"
;
import
{
getVideoThumbnail
,
normalizeVideoUrl
}
from
"@/lib/utils/video"
;
function
VideoPageContent
()
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
searchParams
=
useSearchParams
();
const
pageFromUrl
=
Number
(
searchParams
.
get
(
"page"
)
??
"1"
);
const
page
=
Number
.
isFinite
(
pageFromUrl
)
&&
pageFromUrl
>
0
?
Math
.
floor
(
pageFromUrl
)
:
1
;
const
PAGE_SIZE
=
10
;
const
{
data
:
videosData
,
isLoading
:
videosLoading
,
isError
:
videosError
}
=
useGetApiV10Video
({
page
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
pageData
=
videosData
?.
responseData
;
const
count
=
pageData
?.
count
??
0
;
const
videoPageSize
=
pageData
?.
pageSize
??
PAGE_SIZE
;
const
videos
=
((
pageData
?.
rows
??
[])
as
unknown
as
Video
[]).
map
((
item
)
=>
({
...
item
,
thumbnail
:
getVideoThumbnail
(
item
.
url
??
""
),
watchUrl
:
normalizeVideoUrl
(
item
.
url
??
""
),
}));
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
count
/
videoPageSize
));
const
currentPage
=
pageData
?.
page
??
page
;
const
updatePage
=
(
nextPage
:
number
)
=>
{
const
params
=
new
URLSearchParams
(
searchParams
.
toString
());
if
(
nextPage
>
1
)
{
params
.
set
(
"page"
,
String
(
nextPage
));
}
else
{
params
.
delete
(
"page"
);
}
const
queryString
=
params
.
toString
();
router
.
push
(
queryString
?
`
${
pathname
}
?
${
queryString
}
`
:
pathname
,
{
scroll
:
false
});
};
return
(
<
div
className=
"min-h-screen bg-white"
>
<
div
className=
"container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"
>
<
div
className=
"mb-8"
>
<
h1
className=
"text-3xl font-bold leading-tight text-[#111827] md:text-4xl"
>
Video
</
h1
>
<
div
className=
"mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]"
/>
</
div
>
{
videosLoading
?
(
<
div
className=
"grid gap-6 md:grid-cols-2"
>
{
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
<
div
key=
{
`video-page-loading-${index}`
}
className=
"h-[280px] animate-pulse rounded-[18px] bg-[#edf1f7]"
/>
))
}
</
div
>
)
:
videosError
?
(
<
div
className=
"rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"
>
Không thể tải danh sách video.
</
div
>
)
:
videos
.
length
?
(
<
div
className=
"space-y-8"
>
<
div
className=
"grid gap-6 md:grid-cols-2"
>
{
videos
.
map
((
video
)
=>
(
<
a
key=
{
video
.
id
}
href=
{
video
.
watchUrl
}
target=
"_blank"
rel=
"noreferrer"
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]"
>
<
Image
src=
{
video
.
thumbnail
}
alt=
{
video
.
name
}
width=
{
900
}
height=
{
506
}
className=
"h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
<
div
className=
"absolute inset-0 bg-black/20"
/>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
span
className=
"flex h-14 w-14 items-center justify-center rounded-full bg-white/92 text-[#24469c] shadow-[0_12px_30px_rgba(0,0,0,0.2)]"
>
<
Play
className=
"ml-1 h-6 w-6 fill-current"
/>
</
span
>
</
div
>
</
div
>
<
div
className=
"p-4 sm:p-5"
>
<
h2
className=
"line-clamp-2 text-[17px] font-bold leading-snug text-[#1f3f91] transition-colors group-hover:text-[#0f4386]"
>
{
video
.
name
}
</
h2
>
</
div
>
</
a
>
))
}
</
div
>
{
totalPages
>
1
?
(
<
div
className=
"flex justify-center"
>
<
Pagination
pageCount=
{
totalPages
}
page=
{
currentPage
}
onChangePage=
{
updatePage
}
onGoToPreviousPage=
{
()
=>
updatePage
(
Math
.
max
(
1
,
currentPage
-
1
))
}
onGoToNextPage=
{
()
=>
updatePage
(
Math
.
min
(
totalPages
,
currentPage
+
1
))
}
/>
</
div
>
)
:
null
}
</
div
>
)
:
(
<
div
className=
"rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"
>
Chưa có video nào.
</
div
>
)
}
</
div
>
</
div
>
);
}
type
VideoProps
=
{
category
:
DynamicCategoryRouteItem
|
null
;
allCategories
:
DynamicCategoryRouteItem
[];
};
export
default
function
Video
({
category
,
allCategories
}:
VideoProps
)
{
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
return
(
<>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
Suspense
fallback=
{
<
div
className=
"flex min-h-screen items-center justify-center bg-white"
>
<
Spinner
className=
"size-8"
/>
</
div
>
}
>
<
VideoPageContent
/>
</
Suspense
>
</>
);
}
src/app/(main)/[...slug]/templates/news-detail-page/index.tsx
View file @
ef2f7367
'use client'
;
import
dayjs
from
"dayjs"
;
import
EventsCalendar
from
"@/app/(main)/(home)/components
/events-calendar"
;
import
{
EventsCalendar
}
from
"@/components/base
/events-calendar"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
{
findDisplayCategoryForPost
}
from
"../data"
;
import
{
renderPostSummary
}
from
"./components/post-summary-content"
;
...
...
src/app/(main)/[...slug]/templates/news-page/index.tsx
View file @
ef2f7367
...
...
@@ -9,7 +9,7 @@ import Image from "next/image";
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
EventsCalendar
from
"@/app/(main)/(home)/components
/events-calendar"
;
import
{
EventsCalendar
}
from
"@/components/base
/events-calendar"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
...
...
src/app/(main)/news/news-listing.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
Link
from
"next/link"
;
import
Image
from
"next/image"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
DynamicPostItem
}
from
"@/api/vcci-news/types/post"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
EventsCalendar
}
from
"@/components/base/events-calendar"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
buildDynamicPostHref
,
resolveDynamicPostImage
,
stripHtml
,
}
from
"../[...slug]/templates/data"
;
const
PAGE_SIZE
=
10
;
const
CURRENT_YEAR
=
new
Date
().
getFullYear
();
const
START_YEAR
=
2020
;
const
YEAR_OPTIONS
=
Array
.
from
(
{
length
:
CURRENT_YEAR
-
START_YEAR
+
1
},
(
_
,
index
)
=>
CURRENT_YEAR
-
index
,
);
const
formatPostDate
=
(
value
?:
string
|
null
)
=>
{
if
(
!
value
)
return
""
;
const
date
=
new
Date
(
value
);
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
""
;
return
new
Intl
.
DateTimeFormat
(
"vi-VN"
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
}).
format
(
date
);
};
export
default
function
NewsListing
()
{
const
[
year
,
setYear
]
=
useState
<
number
|
null
>
(
null
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
filterParts
=
useMemo
(()
=>
{
const
parts
=
[
"is_hidden==false"
,
"is_active==true"
,
"type==news"
];
if
(
year
)
{
parts
.
push
(
`published_at>=
${
year
}
-01-01`
);
parts
.
push
(
`published_at<=
${
year
}
-12-31`
);
}
return
parts
.
join
(
","
);
},
[
year
]);
const
{
data
:
postsData
,
isLoading
:
postsLoading
}
=
useGetApiV10Post
({
page
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
priorityFeatured
:
false
,
filters
:
filterParts
,
});
const
responseData
=
postsData
?.
responseData
;
const
count
=
Number
(
responseData
?.
count
??
0
);
const
totalPages
=
PAGE_SIZE
>
0
?
Math
.
max
(
1
,
Math
.
ceil
(
count
/
PAGE_SIZE
))
:
1
;
const
currentPage
=
Math
.
min
(
page
,
totalPages
);
const
posts
=
((
responseData
?.
rows
??
[])
as
unknown
as
DynamicPostItem
[]).
filter
(
(
item
)
=>
item
.
id
&&
item
.
title
,
);
const
handleSelectYear
=
(
value
:
number
|
null
)
=>
{
setYear
(
value
);
setPage
(
1
);
};
return
(
<
div
className=
"min-h-screen bg-white"
>
<
div
className=
"container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10"
>
<
div
className=
"mb-8"
>
<
h1
className=
"text-3xl font-bold leading-tight text-[#111827] md:text-4xl"
>
Tin tức
</
h1
>
<
div
className=
"mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]"
/>
</
div
>
<
div
className=
"flex flex-col gap-10 xl:flex-row xl:gap-14"
>
<
main
className=
"order-2 min-w-0 xl:order-1 xl:flex-1"
>
{
postsLoading
?
(
<
div
className=
"flex h-64 w-full items-center justify-center"
>
<
Spinner
/>
</
div
>
)
:
(
<
div
className=
"space-y-9"
>
{
posts
.
length
?
(
posts
.
map
((
item
)
=>
{
const
date
=
formatPostDate
(
item
.
release_at
??
item
.
published_at
??
item
.
created_at
,
);
const
categoryName
=
item
.
categories
?.[
0
]?.
name
??
""
;
const
excerpt
=
stripHtml
(
item
.
summary
)
||
""
;
return
(
<
article
key=
{
item
.
id
}
className=
"border-b border-[#eceff3] pb-8 last:border-b-0"
>
<
Link
href=
{
buildDynamicPostHref
(
item
.
slug
,
item
.
id
)
}
className=
"group grid items-center gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<
div
className=
"relative aspect-[25/15] overflow-hidden rounded-md bg-[#edf1f5] sm:aspect-[5/3]"
>
<
Image
src=
{
resolveDynamicPostImage
(
item
.
thumbnail
)
}
alt=
{
item
.
title
}
width=
{
520
}
height=
{
360
}
className=
"absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"flex flex-wrap items-center gap-3 text-xs"
>
{
categoryName
?
(
<
span
className=
"rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]"
>
{
categoryName
}
</
span
>
)
:
null
}
{
date
?
(
<
span
className=
"text-[#9aa3ad]"
>
{
date
}
</
span
>
)
:
null
}
</
div
>
<
h2
className=
"mt-3 line-clamp-2 text-[18px] font-bold leading-snug text-[#111827] transition-colors group-hover:text-[#144c9c]"
>
{
item
.
title
}
</
h2
>
{
excerpt
?
(
<
p
className=
"mt-2 line-clamp-3 text-sm leading-6 text-[#5f6875]"
>
{
excerpt
}
</
p
>
)
:
null
}
</
div
>
</
Link
>
</
article
>
);
})
)
:
(
<
div
className=
"rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"
>
<
p
>
{
year
?
`Chưa có bài viết nào trong năm ${year}.`
:
"Chưa có bài viết nào."
}
</
p
>
</
div
>
)
}
<
div
className=
"flex w-full justify-center pt-2"
>
<
Pagination
pageCount=
{
totalPages
}
page=
{
currentPage
}
onChangePage=
{
setPage
}
onGoToPreviousPage=
{
()
=>
setPage
(
Math
.
max
(
1
,
currentPage
-
1
))
}
onGoToNextPage=
{
()
=>
setPage
(
Math
.
min
(
totalPages
,
currentPage
+
1
))
}
/>
</
div
>
</
div
>
)
}
</
main
>
<
aside
className=
"contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5"
>
<
div
className=
"order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-0"
>
<
h2
className=
"text-lg font-bold text-[#111827]"
>
Lọc theo năm
</
h2
>
<
div
className=
"mt-4 flex flex-wrap gap-2"
>
<
button
type=
"button"
onClick=
{
()
=>
handleSelectYear
(
null
)
}
className=
{
`rounded-full px-4 py-2 text-sm font-semibold transition-all ${year === null
? "bg-[#14519f] text-white shadow-[0_10px_20px_rgba(20,81,159,0.18)]"
: "bg-[#f4f7fb] text-[#5f6875] hover:bg-[#eaf0f8]"
}`
}
>
Tất cả
</
button
>
{
YEAR_OPTIONS
.
map
((
option
)
=>
{
const
active
=
year
===
option
;
return
(
<
button
key=
{
option
}
type=
"button"
onClick=
{
()
=>
handleSelectYear
(
active
?
null
:
option
)
}
className=
{
`rounded-full px-4 py-2 text-sm font-semibold transition-all ${active
? "bg-[#14519f] text-white shadow-[0_10px_20px_rgba(20,81,159,0.18)]"
: "bg-[#f4f7fb] text-[#5f6875] hover:bg-[#eaf0f8]"
}`
}
>
{
option
}
</
button
>
);
})
}
</
div
>
{
year
!==
null
?
(
<
Button
type=
"button"
variant=
"outline"
className=
"mt-4 h-10 w-full rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick=
{
()
=>
handleSelectYear
(
null
)
}
>
Bỏ lọc năm
</
Button
>
)
:
null
}
</
div
>
<
EventsCalendar
compact
className=
"xl:w-full xl:min-w-0"
/>
<
SidebarAdvertisements
count=
{
5
}
startIndex=
{
0
}
/>
</
aside
>
</
div
>
</
div
>
</
div
>
);
}
src/app/(main)/news/page.tsx
0 → 100644
View file @
ef2f7367
import
type
{
Metadata
}
from
"next"
;
import
links
from
"@links/index"
;
import
NewsListing
from
"./news-listing"
;
export
const
metadata
:
Metadata
=
{
title
:
"Tin tức"
,
description
:
"Tổng hợp toàn bộ tin tức của VCCI HCM: tin VCCI, tin kinh tế, chuyên đề và các bài viết mới nhất."
,
alternates
:
{
canonical
:
`
${
links
.
siteURL
.
replace
(
/
\/
+$/
,
""
)}
/news`
,
},
};
export
default
function
NewsPage
()
{
return
<
NewsListing
/>;
}
src/app/(main)/search/page.tsx
0 → 100644
View file @
ef2f7367
import
type
{
Metadata
}
from
"next"
;
import
links
from
"@links/index"
;
import
SearchListing
from
"./search-listing"
;
type
SearchPageProps
=
{
searchParams
:
Promise
<
{
q
?:
string
;
page
?:
string
}
>
;
};
export
async
function
generateMetadata
({
searchParams
,
}:
SearchPageProps
):
Promise
<
Metadata
>
{
const
{
q
}
=
await
searchParams
;
const
query
=
(
q
??
""
).
trim
();
const
siteUrl
=
links
.
siteURL
.
replace
(
/
\/
+$/
,
""
);
return
{
title
:
query
?
`Tìm kiếm:
${
query
}
`
:
"Tìm kiếm"
,
description
:
query
?
`Kết quả tìm kiếm "
${
query
}
" trên cổng thông tin VCCI-HCM.`
:
"Tìm kiếm bài viết trên cổng thông tin VCCI-HCM."
,
alternates
:
{
canonical
:
`
${
siteUrl
}
/search`
,
},
};
}
export
default
function
SearchPage
({
searchParams
}:
SearchPageProps
)
{
return
<
SearchListing
searchParams=
{
searchParams
}
/>;
}
src/app/(main)/search/search-form.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
type
SearchFormProps
=
{
defaultValue
?:
string
;
};
/**
* Client island cho form tìm kiếm, đẩy `q` + `page` lên URL
* để server component đọc searchParams và fetch dữ liệu.
*/
export
default
function
SearchForm
({
defaultValue
=
""
}:
SearchFormProps
)
{
const
router
=
useRouter
();
const
[
value
,
setValue
]
=
useState
(
defaultValue
);
useEffect
(()
=>
{
setValue
(
defaultValue
);
},
[
defaultValue
]);
const
goToSearch
=
(
nextQuery
:
string
)
=>
{
const
params
=
new
URLSearchParams
();
const
trimmedQuery
=
nextQuery
.
trim
();
if
(
trimmedQuery
)
params
.
set
(
"q"
,
trimmedQuery
);
params
.
set
(
"page"
,
"1"
);
router
.
push
(
`/search?
${
params
.
toString
()}
`
,
{
scroll
:
false
});
};
return
(
<
form
className=
"order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-none"
onSubmit=
{
(
event
)
=>
{
event
.
preventDefault
();
goToSearch
(
value
);
}
}
>
<
h2
className=
"text-lg font-bold text-[#111827]"
>
Tìm kiếm
</
h2
>
<
Input
value=
{
value
}
onChange=
{
(
event
)
=>
setValue
(
event
.
target
.
value
)
}
placeholder=
"Tên bài viết ..."
className=
"mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<
div
className=
"mt-4 grid grid-cols-2 gap-3"
>
<
Button
type=
"submit"
className=
"h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</
Button
>
<
Button
type=
"button"
variant=
"outline"
className=
"h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick=
{
()
=>
{
setValue
(
""
);
goToSearch
(
""
);
}
}
>
Bỏ tìm
</
Button
>
</
div
>
</
form
>
);
}
src/app/(main)/
[...slug]/static-pages/Search
.tsx
→
src/app/(main)/
search/search-listing
.tsx
View file @
ef2f7367
"use client"
;
import
{
Suspense
,
useEffect
,
useState
}
from
"react"
;
import
Link
from
"next/link"
;
import
{
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
Image
from
"next/image"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
PaginationNav
from
"@/components/base/pagination-nav"
;
import
SidebarAdvertisements
from
"@/components/shared/sidebar-advertisements"
;
import
{
Pagination
}
from
"@components/base/pagination"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Spinner
}
from
"@components/ui/spinner"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
}
from
"../templates/data"
;
import
type
{
DynamicCategoryRouteItem
}
from
"../templates/types"
;
import
{
useGetApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
buildDynamicPostHref
,
getDynamicPostExcerpt
,
resolveDynamicPostImage
,
mapPost
,
}
from
"../templates/data"
;
resolveDynamicPostImage
,
}
from
"../[...slug]/templates/data"
;
import
type
{
DynamicPostItem
}
from
"@/api/vcci-news/types/post"
;
import
SearchForm
from
"./search-form"
;
const
PAGE_SIZE
=
10
;
const
formatPostDate
=
(
value
?:
string
|
null
)
=>
{
if
(
!
value
)
return
""
;
...
...
@@ -68,7 +61,9 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
<
div
className=
"min-w-0 pt-1"
>
<
div
className=
"flex flex-wrap items-center gap-3 text-xs"
>
<
span
className=
{
`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(index)}`
}
>
<
span
className=
{
`rounded-full px-2.5 py-1 font-semibold ${getTagClassName(index)}`
}
>
{
categoryName
}
</
span
>
{
date
?
<
span
className=
"text-[#9aa3ad]"
>
{
date
}
</
span
>
:
null
}
...
...
@@ -89,15 +84,18 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
);
}
function
SearchContent
()
{
const
router
=
useRouter
();
const
searchParams
=
useSearchParams
();
const
query
=
searchParams
.
get
(
"q"
)
||
""
;
const
pageFromUrl
=
searchParams
.
get
(
"page"
);
const
[
page
,
setPage
]
=
useState
(
pageFromUrl
?
Number
(
pageFromUrl
)
:
1
);
const
[
searchInput
,
setSearchInput
]
=
useState
(
query
);
type
SearchListingProps
=
{
searchParams
:
Promise
<
{
q
?:
string
;
page
?:
string
}
>
;
};
export
default
async
function
SearchListing
({
searchParams
}:
SearchListingProps
)
{
const
{
q
,
page
:
pageParam
}
=
await
searchParams
;
const
query
=
(
q
??
""
).
trim
();
const
pageFromUrl
=
Number
(
pageParam
??
"1"
);
const
page
=
Number
.
isFinite
(
pageFromUrl
)
&&
pageFromUrl
>
0
?
Math
.
floor
(
pageFromUrl
)
:
1
;
const
pageSize
=
10
;
const
filters
=
[
query
?
`title@=
${
query
}
`
:
null
,
"is_hidden==false"
,
...
...
@@ -108,41 +106,39 @@ function SearchContent() {
.
filter
(
Boolean
)
.
join
(
","
);
const
{
data
:
postsData
,
isLoading
:
postsLoading
}
=
useGetApiV10Post
({
let
rows
:
DynamicPostItem
[]
=
[];
let
count
=
0
;
let
pageSize
=
PAGE_SIZE
;
let
currentPage
=
page
;
try
{
const
response
=
(
await
getApiV10Post
({
page
,
pageSize
,
pageSize
:
PAGE_SIZE
,
sortField
:
"release_at"
,
sortOrder
:
"desc"
,
filters
:
filters
||
undefined
,
});
filters
,
}))
as
{
responseData
?:
{
count
?:
number
;
page
?:
number
;
pageSize
?:
number
;
rows
?:
unknown
[];
};
};
const
responseData
=
postsData
?.
responseData
;
const
count
=
Number
(
respons
eData
?.
count
??
0
);
const
totalPages
=
pageSize
>
0
?
Math
.
max
(
1
,
Math
.
ceil
(
count
/
pageSize
))
:
1
;
const
currentPage
=
Math
.
min
(
page
,
totalPages
)
;
const
rows
=
((
respons
eData
?.
rows
??
[])
as
unknown
as
Parameters
<
typeof
mapPost
>
[
0
][])
const
pageData
=
response
?.
responseData
;
count
=
Number
(
pag
eData
?.
count
??
0
);
pageSize
=
Number
(
pageData
?.
pageSize
??
PAGE_SIZE
)
||
PAGE_SIZE
;
currentPage
=
Number
(
pageData
?.
page
??
page
)
||
page
;
rows
=
((
pag
eData
?.
rows
??
[])
as
unknown
as
Parameters
<
typeof
mapPost
>
[
0
][])
.
map
(
mapPost
)
.
filter
((
item
:
DynamicPostItem
)
=>
item
.
id
&&
item
.
title
);
useEffect
(()
=>
{
const
nextPage
=
pageFromUrl
?
Number
(
pageFromUrl
)
:
1
;
if
(
Number
.
isFinite
(
nextPage
))
{
setPage
(
nextPage
);
.
filter
((
item
)
=>
item
.
id
&&
item
.
title
);
}
catch
{
rows
=
[];
}
},
[
pageFromUrl
]);
useEffect
(()
=>
{
setSearchInput
(
query
);
},
[
query
]);
const
updateSearchUrl
=
(
nextQuery
:
string
,
nextPage
=
1
)
=>
{
const
params
=
new
URLSearchParams
();
const
trimmedQuery
=
nextQuery
.
trim
();
if
(
trimmedQuery
)
params
.
set
(
"q"
,
trimmedQuery
);
params
.
set
(
"page"
,
String
(
nextPage
));
router
.
push
(
`/search?
${
params
.
toString
()}
`
,
{
scroll
:
false
});
};
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
count
/
pageSize
));
return
(
<
div
className=
"min-h-screen bg-white"
>
...
...
@@ -154,19 +150,14 @@ function SearchContent() {
<
div
className=
"mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]"
/>
{
query
?
(
<
p
className=
"mt-4 text-sm text-[#5f6875]"
>
Kết quả tìm kiếm cho:
<
span
className=
"font-semibold text-[#111827]"
>
{
query
}
</
span
>
Kết quả tìm kiếm cho:
{
" "
}
<
span
className=
"font-semibold text-[#111827]"
>
{
query
}
</
span
>
</
p
>
)
:
null
}
</
div
>
<
div
className=
"flex flex-col gap-10 xl:flex-row xl:gap-14"
>
<
main
className=
"order-2 min-w-0 xl:order-1 xl:flex-1"
>
{
postsLoading
?
(
<
div
className=
"flex items-center justify-center py-16"
>
<
Spinner
className=
"size-8"
/>
<
span
className=
"ml-2 text-gray-600"
>
Đang tìm kiếm...
</
span
>
</
div
>
)
:
(
<
div
className=
"space-y-9"
>
{
rows
.
length
?
(
rows
.
map
((
item
,
index
)
=>
(
...
...
@@ -178,67 +169,17 @@ function SearchContent() {
</
div
>
)
}
<
div
className=
"flex w-full justify-center pt-2"
>
<
Pagination
<
PaginationNav
page=
{
Math
.
min
(
currentPage
,
totalPages
)
}
pageCount=
{
totalPages
}
page=
{
currentPage
}
onChangePage=
{
(
nextPage
)
=>
{
setPage
(
nextPage
);
updateSearchUrl
(
query
,
nextPage
);
}
}
onGoToPreviousPage=
{
()
=>
{
const
nextPage
=
Math
.
max
(
1
,
currentPage
-
1
);
setPage
(
nextPage
);
updateSearchUrl
(
query
,
nextPage
);
}
}
onGoToNextPage=
{
()
=>
{
const
nextPage
=
Math
.
min
(
totalPages
,
currentPage
+
1
);
setPage
(
nextPage
);
updateSearchUrl
(
query
,
nextPage
);
}
}
basePath=
"/search"
keepParams=
{
[{
key
:
"q"
,
value
:
query
}]
}
/>
</
div
>
</
div
>
)
}
</
main
>
<
aside
className=
"contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5 xl:pt-0"
>
<
form
className=
"order-1 rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)] xl:order-none"
onSubmit=
{
(
event
)
=>
{
event
.
preventDefault
();
setPage
(
1
);
updateSearchUrl
(
searchInput
,
1
);
}
}
>
<
h2
className=
"text-lg font-bold text-[#111827]"
>
Tìm kiếm
</
h2
>
<
Input
value=
{
searchInput
}
onChange=
{
(
event
)
=>
setSearchInput
(
event
.
target
.
value
)
}
placeholder=
"Tên bài viết ..."
className=
"mt-4 h-11 rounded-xl border-[#edf1f5] bg-[#f8fafc] text-sm placeholder:text-gray-700"
/>
<
div
className=
"mt-4 grid grid-cols-2 gap-3"
>
<
Button
type=
"submit"
className=
"h-11 rounded-xl bg-[#14519f] text-white hover:bg-[#0f4386]"
>
Tìm kiếm
</
Button
>
<
Button
type=
"button"
variant=
"outline"
className=
"h-11 rounded-xl border-[#edf1f5] bg-white text-[#4b5563]"
onClick=
{
()
=>
{
setSearchInput
(
""
);
setPage
(
1
);
updateSearchUrl
(
""
,
1
);
}
}
>
Bỏ tìm
</
Button
>
</
div
>
</
form
>
<
SearchForm
defaultValue=
{
query
}
/>
<
SidebarAdvertisements
count=
{
5
}
startIndex=
{
0
}
/>
</
aside
>
...
...
@@ -247,27 +188,3 @@ function SearchContent() {
</
div
>
);
}
type
SearchProps
=
{
category
:
DynamicCategoryRouteItem
|
null
;
allCategories
:
DynamicCategoryRouteItem
[];
};
export
default
function
Search
({
category
,
allCategories
}:
SearchProps
)
{
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
return
(
<>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
Suspense
fallback=
{
<
div
className=
"flex min-h-screen items-center justify-center bg-white"
>
<
Spinner
className=
"size-8"
/>
</
div
>
}
>
<
SearchContent
/>
</
Suspense
>
</>
);
}
src/app/(main)/site-map/page.tsx
0 → 100644
View file @
ef2f7367
import
type
{
Metadata
}
from
"next"
;
import
links
from
"@links/index"
;
import
SiteMapListing
from
"./site-map-listing"
;
export
const
metadata
:
Metadata
=
{
title
:
"Sơ đồ trang web"
,
description
:
"Sơ đồ trang web VCCI-HCM: tổng quan các chuyên mục và bài viết trên website."
,
alternates
:
{
canonical
:
`
${
links
.
siteURL
.
replace
(
/
\/
+$/
,
""
)}
/site-map`
,
},
};
export
default
function
SiteMapPage
()
{
return
<
SiteMapListing
/>;
}
src/app/(main)/site-map/site-map-listing.tsx
0 → 100644
View file @
ef2f7367
import
Link
from
"next/link"
;
import
{
getApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
export
default
async
function
SiteMapListing
()
{
let
sections
:
HeaderCategoryTreeItem
[]
=
[];
try
{
const
response
=
(
await
getApiV10CategoryTree
())
as
{
responseData
?:
HeaderCategoryTreeItem
[];
};
sections
=
response
?.
responseData
??
[];
}
catch
{
sections
=
[];
}
if
(
!
sections
.
length
)
{
return
(
<
div
className=
"flex min-h-screen items-center justify-center bg-gray-50 py-12"
>
<
div
className=
"text-xl font-semibold text-red-600"
>
Không thể tải dữ liệu
</
div
>
</
div
>
);
}
return
(
<
div
className=
"min-h-screen bg-gray-50 py-12"
>
<
div
className=
"container mx-auto px-4"
>
<
h1
className=
"mb-12 text-center text-3xl font-bold text-[#063e8e]"
>
SƠ ĐỒ TRANG WEB
</
h1
>
{
/* Sitemap Structure */
}
<
div
className=
"relative flex flex-col items-center"
>
{
/* Homepage - Top Level */
}
<
div
className=
"relative mb-20"
>
<
Link
href=
"/"
className=
"block min-w-[200px] rounded-lg bg-[#063e8e] px-8 py-4 text-center font-semibold text-white shadow-lg transition hover:bg-[#0a4fb5]"
>
TRANG CHỦ
</
Link
>
{
/* Vertical line from homepage down */
}
<
div
className=
"absolute left-[99px] top-full h-20 w-0.5 -translate-x-1/2 bg-gray-600"
/>
</
div
>
{
/* Main Sections - Second Level */
}
<
div
className=
"relative w-full max-w-[1400px]"
>
{
/* Horizontal line connecting all sections */
}
<
div
className=
"absolute left-[6.3%] right-[6.3%] top-0 z-0 h-0.5 bg-gray-600"
/>
<
div
className=
"relative grid grid-cols-2 gap-6 pt-4 md:grid-cols-4 lg:grid-cols-7"
>
{
sections
.
map
((
section
)
=>
(
<
div
key=
{
section
.
id
}
className=
"relative flex flex-col items-center"
>
{
/* Vertical line from horizontal bar down to section */
}
<
div
className=
"absolute -top-4 left-1/2 z-10 h-4 w-0.5 -translate-x-1/2 bg-gray-600"
/>
{
/* Section Box */
}
<
div
className=
"relative z-20"
>
<
Link
href=
{
section
.
url
||
"#"
}
className=
"flex min-h-20 w-full items-center justify-center rounded-md bg-[#063e8e] px-4 py-3 text-center text-sm font-medium text-white shadow-md transition hover:bg-[#0a4fb5]"
>
<
span
className=
"leading-tight"
>
{
section
.
name
.
toUpperCase
()
}
</
span
>
</
Link
>
{
/* Vertical line from section down to children */
}
{
section
.
children
&&
section
.
children
.
length
>
0
?
(
<
div
className=
"absolute left-1/2 top-full z-10 h-6 w-0.5 -translate-x-1/2 bg-gray-600"
/>
)
:
null
}
</
div
>
{
/* Children - Third Level */
}
{
section
.
children
&&
section
.
children
.
length
>
0
?
(
<
div
className=
"relative mt-6 flex w-full flex-col gap-3"
>
{
/* Vertical spine connecting all children */
}
<
div
className=
"absolute left-1/2 w-0.5 -translate-x-1/2 bg-gray-600"
style=
{
{
top
:
"-24px"
,
bottom
:
"0"
}
}
/>
{
section
.
children
.
map
((
child
)
=>
(
<
div
key=
{
child
.
id
}
className=
"relative"
>
{
/* Horizontal line from spine to child box */
}
<
div
className=
"absolute right-1/2 top-1/2 h-0.5 w-1/2 -translate-y-1/2 bg-gray-600"
/>
<
Link
href=
{
child
.
url
||
"#"
}
className=
"relative z-10 block rounded bg-gray-400 px-3 py-2.5 text-center text-xs font-medium leading-tight text-white shadow-sm transition hover:bg-gray-500"
>
{
child
.
name
.
toUpperCase
()
}
</
Link
>
</
div
>
))
}
</
div
>
)
:
null
}
</
div
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
);
}
src/app/(main)/video/page.tsx
0 → 100644
View file @
ef2f7367
import
type
{
Metadata
}
from
"next"
;
import
links
from
"@links/index"
;
import
VideoListing
from
"./video-listing"
;
export
const
metadata
:
Metadata
=
{
title
:
"Video"
,
description
:
"Thư viện video VCCI-HCM: các phóng sự, sự kiện, hội thảo và hoạt động xúc tiến thương mại."
,
alternates
:
{
canonical
:
`
${
links
.
siteURL
.
replace
(
/
\/
+$/
,
""
)}
/video`
,
},
};
type
VideoPageProps
=
{
searchParams
:
Promise
<
{
page
?:
string
}
>
;
};
export
default
function
VideoPage
({
searchParams
}:
VideoPageProps
)
{
return
<
VideoListing
searchParams=
{
searchParams
}
/>;
}
src/app/(main)/video/video-listing.tsx
0 → 100644
View file @
ef2f7367
import
{
Play
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
{
getApiV10Video
}
from
"@/api/vcci-news/endpoints/video"
;
import
type
{
Video
}
from
"@/api/vcci-news/models/video"
;
import
PaginationNav
from
"@/components/base/pagination-nav"
;
import
{
getVideoThumbnail
,
normalizeVideoUrl
}
from
"@/lib/utils/video"
;
const
PAGE_SIZE
=
10
;
type
VideoListProps
=
{
searchParams
:
Promise
<
{
page
?:
string
}
>
;
};
export
default
async
function
VideoListing
({
searchParams
}:
VideoListProps
)
{
const
{
page
:
pageParam
}
=
await
searchParams
;
const
pageFromUrl
=
Number
(
pageParam
??
"1"
);
const
page
=
Number
.
isFinite
(
pageFromUrl
)
&&
pageFromUrl
>
0
?
Math
.
floor
(
pageFromUrl
)
:
1
;
let
videos
:
Video
[]
=
[];
let
count
=
0
;
let
pageSize
=
PAGE_SIZE
;
let
currentPage
=
page
;
try
{
const
response
=
await
getApiV10Video
({
page
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
pageData
=
(
response
as
{
responseData
?:
{
count
?:
number
;
page
?:
number
;
pageSize
?:
number
;
rows
?:
Video
[];
};
}
|
undefined
)?.
responseData
;
count
=
Number
(
pageData
?.
count
??
0
);
pageSize
=
Number
(
pageData
?.
pageSize
??
PAGE_SIZE
)
||
PAGE_SIZE
;
currentPage
=
Number
(
pageData
?.
page
??
page
)
||
page
;
videos
=
pageData
?.
rows
??
[];
}
catch
{
videos
=
[];
}
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
count
/
pageSize
));
return
(
<
div
className=
"min-h-screen bg-white"
>
<
div
className=
"container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"
>
<
div
className=
"mb-8"
>
<
h1
className=
"text-3xl font-bold leading-tight text-[#111827] md:text-4xl"
>
Video
</
h1
>
<
div
className=
"mt-2 h-[3px] w-16 rounded-full bg-[#f5a400]"
/>
</
div
>
{
videos
.
length
?
(
<
div
className=
"space-y-8"
>
<
div
className=
"grid gap-6 md:grid-cols-2"
>
{
videos
.
map
((
video
)
=>
{
const
url
=
video
.
url
??
""
;
const
thumbnail
=
getVideoThumbnail
(
url
);
const
watchUrl
=
normalizeVideoUrl
(
url
);
return
(
<
a
key=
{
video
.
id
}
href=
{
watchUrl
}
target=
"_blank"
rel=
"noreferrer"
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]"
>
<
Image
src=
{
thumbnail
}
alt=
{
video
.
name
??
"Video"
}
width=
{
900
}
height=
{
506
}
className=
"h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
<
div
className=
"absolute inset-0 bg-black/20"
/>
<
div
className=
"absolute inset-0 flex items-center justify-center"
>
<
span
className=
"flex h-14 w-14 items-center justify-center rounded-full bg-white/92 text-[#24469c] shadow-[0_12px_30px_rgba(0,0,0,0.2)]"
>
<
Play
className=
"ml-1 h-6 w-6 fill-current"
/>
</
span
>
</
div
>
</
div
>
<
div
className=
"p-4 sm:p-5"
>
<
h2
className=
"line-clamp-2 text-[17px] font-bold leading-snug text-[#1f3f91] transition-colors group-hover:text-[#0f4386]"
>
{
video
.
name
}
</
h2
>
</
div
>
</
a
>
);
})
}
</
div
>
{
totalPages
>
1
?
(
<
PaginationNav
page=
{
currentPage
}
pageCount=
{
totalPages
}
basePath=
"/video"
/>
)
:
null
}
</
div
>
)
:
(
<
div
className=
"rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"
>
Chưa có video nào.
</
div
>
)
}
</
div
>
</
div
>
);
}
src/
app/(main)/(home)/components
/events-calendar/index.tsx
→
src/
components/base
/events-calendar/index.tsx
View file @
ef2f7367
...
...
@@ -5,7 +5,10 @@ import { addMonths, format, getDay, startOfMonth, subMonths } from "date-fns";
import
dayjs
from
"dayjs"
;
import
{
ChevronLeft
,
ChevronRight
}
from
"lucide-react"
;
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useEventCalendarPosts
,
type
HomePostItem
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
{
useEventCalendarPosts
,
type
EventCalendarItem
,
}
from
"./use-event-calendar-posts"
;
import
{
HoverCard
,
HoverCardContent
,
HoverCardTrigger
}
from
"@/components/ui/hover-card"
;
import
{
cn
}
from
"@/lib/utils"
;
...
...
@@ -14,7 +17,7 @@ const weekDays = ["CN", "T2", "T3", "T4", "T5", "T6", "T7"];
const
formatDateTime
=
(
value
:
string
)
=>
value
?
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
)
:
"Đang cập nhật"
;
const
getEventDateRange
=
(
item
:
HomePost
Item
)
=>
{
const
getEventDateRange
=
(
item
:
EventCalendar
Item
)
=>
{
// Ưu tiên event_dates (ngày cụ thể)
if
(
item
.
eventDates
&&
item
.
eventDates
.
length
>
0
)
{
return
item
.
eventDates
.
map
((
d
)
=>
dayjs
(
d
).
format
(
"YYYY-MM-DD"
)).
filter
(
Boolean
);
...
...
@@ -49,13 +52,13 @@ const getEventDateRange = (item: HomePostItem) => {
return
[];
};
const
isTrainingEvent
=
(
item
:
HomePost
Item
)
=>
const
isTrainingEvent
=
(
item
:
EventCalendar
Item
)
=>
item
.
categories
.
some
((
category
)
=>
{
const
key
=
`
${
category
.
name
}
${
category
.
slug
}
${
category
.
url
}
`
.
toLowerCase
();
return
key
.
includes
(
"đào tạo"
)
||
key
.
includes
(
"dao-tao"
);
});
const
getDayVariant
=
(
items
:
HomePost
Item
[])
=>
{
const
getDayVariant
=
(
items
:
EventCalendar
Item
[])
=>
{
const
hasTraining
=
items
.
some
((
item
)
=>
isTrainingEvent
(
item
));
const
hasEvent
=
items
.
some
((
item
)
=>
!
isTrainingEvent
(
item
));
...
...
@@ -65,7 +68,7 @@ const getDayVariant = (items: HomePostItem[]) => {
return
"default"
;
};
function
EventsCalendar
({
export
function
EventsCalendar
({
className
,
compact
=
false
,
}:
{
...
...
@@ -102,7 +105,7 @@ function EventsCalendar({
},
[
currentMonth
]);
const
eventMap
=
useMemo
(()
=>
{
const
map
=
new
Map
<
string
,
HomePost
Item
[]
>
();
const
map
=
new
Map
<
string
,
EventCalendar
Item
[]
>
();
monthEvents
.
forEach
((
item
)
=>
{
const
eventDates
=
getEventDateRange
(
item
);
...
...
@@ -394,5 +397,3 @@ function EventsCalendar({
</
aside
>
);
}
\ No newline at end of file
export
default
EventsCalendar
;
src/components/base/events-calendar/use-event-calendar-posts.ts
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
dayjs
from
"dayjs"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawEventCalendarPost
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
type
{
GetApiV10PostParams
}
from
"@/api/vcci-news/models/getApiV10PostParams"
;
import
{
buildPostLink
,
normalizeLink
,
resolveAssetUrl
,
}
from
"@/lib/utils/post"
;
import
{
MOCK_HOME_POSTS
}
from
"@/mockdata/home-posts"
;
import
{
useMemo
}
from
"react"
;
type
RawHomePost
=
RawEventCalendarPost
;
export
type
EventCalendarCategory
=
{
id
:
string
;
name
:
string
;
slug
:
string
;
url
:
string
;
type
:
string
;
};
export
type
EventCalendarItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
summary
:
string
;
contentText
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
startedAt
:
string
;
endedAt
:
string
;
registrationDeadline
:
string
;
location
:
string
;
participationFee
:
string
;
expiredAt
:
string
;
isFeatured
:
boolean
;
isHidden
:
boolean
;
isActive
:
boolean
;
status
:
string
;
type
:
string
;
eventDates
?:
string
[];
categories
:
EventCalendarCategory
[];
thumbnail
:
{
url
:
string
;
alt
:
string
;
}
|
null
;
};
const
EVENT_CATEGORY_IDS
=
{
suKien
:
"b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a"
,
daoTao
:
"36df7021-9a74-43d6-9084-0d5ed347b7f4"
,
}
as
const
;
const
mapRawPostToEventCalendarItem
=
(
item
:
RawHomePost
):
EventCalendarItem
=>
{
const
categories
=
(
item
.
categories
??
[])
.
filter
((
category
)
=>
category
?.
id
&&
category
?.
name
)
.
map
((
category
)
=>
({
id
:
String
(
category
.
id
),
name
:
String
(
category
.
name
),
slug
:
String
(
category
.
slug
??
""
),
url
:
normalizeLink
(
category
.
url
,
"#"
),
type
:
String
(
category
.
type
??
""
),
}));
const
thumbnailPath
=
item
.
thumbnail
?.
path
??
item
.
thumbnail
?.
original
??
null
;
const
title
=
String
(
item
.
title
??
""
).
trim
();
const
externalLink
=
buildPostLink
(
item
.
slug
?
`/
${
item
.
slug
}
`
:
(
title
?
`/
${
title
}
`
:
undefined
),
item
.
id
?
String
(
item
.
id
)
:
""
,
"#"
,
);
return
{
id
:
String
(
item
.
id
??
""
),
title
,
externalLink
,
summary
:
String
(
item
.
summary
??
item
.
content
??
""
),
contentText
:
String
(
item
.
content_structure
?.
post_content
?.[
0
]?.
content
??
item
.
summary
??
item
.
content
??
""
),
createdAt
:
String
(
item
.
created_at
??
""
),
publishedAt
:
String
(
item
.
published_at
??
item
.
release_at
??
item
.
created_at
??
""
),
startedAt
:
String
(
item
.
started_at
??
""
),
endedAt
:
String
(
item
.
ended_at
??
""
),
registrationDeadline
:
String
(
item
.
registration_deadline
??
""
),
location
:
String
(
item
.
location
??
""
),
participationFee
:
String
(
item
.
participation_fee
??
""
),
expiredAt
:
String
(
item
.
expired_at
??
""
),
isFeatured
:
Boolean
(
item
.
is_featured
),
isHidden
:
Boolean
(
item
.
is_hidden
),
isActive
:
item
.
is_active
!==
false
,
status
:
String
(
item
.
status
??
""
),
type
:
String
(
item
.
type
??
""
),
eventDates
:
Array
.
isArray
(
item
.
event_dates
)
?
item
.
event_dates
.
filter
((
d
):
d
is
string
=>
typeof
d
===
"string"
)
:
[],
categories
,
thumbnail
:
thumbnailPath
?
{
url
:
resolveAssetUrl
(
thumbnailPath
),
alt
:
title
,
}
:
null
,
};
};
async
function
fetchEventCalendarRows
(
params
:
GetApiV10PostParams
)
{
const
response
=
await
getApiV10Post
(
params
);
const
rows
=
(
response
as
PagedResult
<
RawHomePost
>
|
undefined
)?.
responseData
?.
rows
??
[];
return
rows
.
map
<
EventCalendarItem
>
(
mapRawPostToEventCalendarItem
);
}
/**
* Params lịch sự kiện theo tháng: filter theo tháng được thực hiện hoàn toàn
* ở BE. Toán tử `[]` = date between; đặt `registration_deadline` cuối cùng
* (trước `[]`) để BE nhận diện đây là monthly-event query (pageSize=0 => trả
* toàn bộ, không giới hạn). Cú pháp `field1|field2|field3[](start|end)` tạo
* điều kiện OR: event rơi vào tháng khi registration_deadline / started_at /
* ended_at nằm trong khoảng tháng đó.
*/
function
createEventCalendarParams
(
currentMonth
:
Date
):
GetApiV10PostParams
{
const
monthStart
=
dayjs
(
currentMonth
).
startOf
(
"month"
).
format
(
"YYYY-MM-DD"
);
const
monthEnd
=
dayjs
(
currentMonth
).
endOf
(
"month"
).
format
(
"YYYY-MM-DDTHH:mm:ss"
);
return
{
page
:
1
,
pageSize
:
0
,
sortField
:
"started_at"
,
sortOrder
:
"asc"
,
priorityFeatured
:
false
,
filters
:
[
`category.id==(
${
EVENT_CATEGORY_IDS
.
suKien
}
|
${
EVENT_CATEGORY_IDS
.
daoTao
}
)`
,
"is_hidden==false"
,
"is_active==true"
,
"type==event"
,
`registration_deadline|started_at|ended_at[](
${
monthStart
}
|
${
monthEnd
}
)`
,
].
join
(
","
),
};
}
export
function
useEventCalendarPosts
(
currentMonth
:
Date
)
{
const
params
=
useMemo
(
()
=>
createEventCalendarParams
(
currentMonth
),
[
currentMonth
],
);
const
query
=
useQuery
({
queryKey
:
[
"event-calendar-posts"
,
params
]
as
const
,
queryFn
:
async
()
=>
{
try
{
return
await
fetchEventCalendarRows
(
params
);
}
catch
(
error
)
{
// eslint-disable-next-line no-console
console
.
warn
(
"[useEventCalendarPosts] CMS unavailable, falling back to mock data"
,
error
);
return
MOCK_HOME_POSTS
.
filter
((
item
)
=>
Boolean
(
item
.
registrationDeadline
));
}
},
staleTime
:
5
*
60
*
1000
,
});
return
query
;
}
src/components/base/pagination-nav/index.tsx
0 → 100644
View file @
ef2f7367
"use client"
;
import
{
usePathname
,
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
type
PaginationNavProps
=
{
page
:
number
;
pageCount
:
number
;
/** Đường dẫn base, ví dụ `/video`. */
basePath
:
string
;
/** Danh sách query params cần giữ lại khi chuyển trang. */
keepParams
?:
{
key
:
string
;
value
?:
string
|
null
}[];
};
/**
* Client island cho phân trang dùng URL query, dùng được từ Server Component.
*/
export
default
function
PaginationNav
({
page
,
pageCount
,
basePath
,
keepParams
=
[],
}:
PaginationNavProps
)
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
searchParams
=
useSearchParams
();
if
(
pageCount
<=
1
)
return
null
;
const
goToPage
=
(
nextPage
:
number
)
=>
{
const
params
=
new
URLSearchParams
();
for
(
const
param
of
keepParams
)
{
const
value
=
param
.
value
?.
trim
();
if
(
value
)
params
.
set
(
param
.
key
,
value
);
}
if
(
nextPage
>
1
)
params
.
set
(
"page"
,
String
(
nextPage
));
const
queryString
=
params
.
toString
();
router
.
push
(
queryString
?
`
${
pathname
||
basePath
}
?
${
queryString
}
`
:
pathname
||
basePath
,
{
scroll
:
false
},
);
};
return
(
<
div
className=
"flex w-full justify-center pt-2"
>
<
Pagination
pageCount=
{
pageCount
}
page=
{
page
}
onChangePage=
{
goToPage
}
onGoToPreviousPage=
{
()
=>
goToPage
(
Math
.
max
(
1
,
page
-
1
))
}
onGoToNextPage=
{
()
=>
goToPage
(
Math
.
min
(
pageCount
,
page
+
1
))
}
/>
</
div
>
);
}
\ No newline at end of file
src/components/shared/sidebar-advertisements/index.tsx
View file @
ef2f7367
'use client'
;
"use client"
;
import
{
useMemo
}
from
"react"
;
import
{
useAdvertisements
}
from
"@/app/(main)/(home)/lib/use-advertisements"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
getApiV10AdvertisementPublic
}
from
"@/api/vcci-news/endpoints/advertisement"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
import
{
getFallbackImage
}
from
"@/lib/utils/fallback-image"
;
import
{
SidebarAdItem
}
from
"./components/sidebar-ad-item"
;
import
{
FallbackSidebarAdItem
}
from
"./components/fallback-sidebar-ad-item"
;
/** Lấy danh sách quảng cáo square từ API public (BE đã lọc ACTIVE, sort sort_order ASC). */
const
fetchSquareAds
=
async
():
Promise
<
Advertisement
[]
>
=>
{
try
{
const
response
=
await
getApiV10AdvertisementPublic
({
type
:
"square"
,
limit
:
20
,
});
return
(
(
response
as
unknown
as
{
responseData
?:
Advertisement
[]
}
|
undefined
)
?.
responseData
??
[]
);
}
catch
(
error
)
{
console
.
warn
(
"[SidebarAdvertisements] CMS unavailable, using fallback"
,
error
);
return
[];
}
};
function
SidebarAdvertisements
({
count
=
5
,
startIndex
=
0
}:
{
count
?:
number
;
startIndex
?:
number
})
{
const
ads
=
useAdvertisements
(
"square"
);
const
{
data
:
ads
=
[]
}
=
useQuery
({
queryKey
:
[
"sidebar-advertisements"
,
"square"
]
as
const
,
queryFn
:
fetchSquareAds
,
staleTime
:
60
*
1000
,
});
const
visibleAds
=
ads
.
slice
(
startIndex
,
startIndex
+
count
);
const
fallbackSrcs
=
useMemo
(
...
...
src/lib/types/common.ts
deleted
100644 → 0
View file @
d3f4f2a6
// Query response type
interface
ResponseType
<
T
=
any
>
{
data
:
any
message
:
string
|
null
message_en
:
string
|
null
responseData
:
T
status
:
'fail'
|
'success'
statusCode
:
number
timeStamp
:
string
violations
:
Array
<
{
code
:
number
message
:
string
action
:
Array
<
{
location
:
string
msg
:
{
en
:
string
vi
:
string
}
path
:
string
value
:
string
}
>
}
>
|
null
}
// Infinite query statuses type
interface
InfiniteQueryStatusesType
{
isFetchingInitialPage
:
boolean
isFetchingNextPage
:
boolean
isEmptyData
:
boolean
isError
?:
boolean
}
export
type
{
ResponseType
,
InfiniteQueryStatusesType
}
src/lib/types/google.ts
deleted
100644 → 0
View file @
d3f4f2a6
export
type
GoogleRedirectResponse
<
Flow
=
AuthorizationCodeFlow
|
ImplicitGrantFlow
>
=
|
GoogleResponseSuccess
<
Flow
>
|
GoogleResponseError
export
type
AuthorizationCodeFlow
=
{
code
:
string
}
export
type
ImplicitGrantFlow
=
{
access_token
:
string
;
token_type
:
string
;
expires_in
:
string
}
export
type
GoogleResponseSuccess
<
Flow
=
AuthorizationCodeFlow
|
ImplicitGrantFlow
>
=
{
state
:
string
scope
:
string
authuser
:
string
prompt
:
string
}
&
Flow
export
type
GoogleResponseError
=
{
error
:
string
}
export
type
GoogleRedirectResponseQueryKeys
=
|
keyof
GoogleResponseSuccess
<
AuthorizationCodeFlow
>
|
keyof
GoogleResponseSuccess
<
ImplicitGrantFlow
>
|
keyof
GoogleResponseError
export
interface
GenerateAuthUrlOpts
{
/**
* Recommended. Indicates whether your application can refresh access tokens
* when the user is not present at the browser. Valid parameter values are
* 'online', which is the default value, and 'offline'. Set the value to
* 'offline' if your application needs to refresh access tokens when the user
* is not present at the browser. This value instructs the Google
* authorization server to return a refresh token and an access token the
* first time that your application exchanges an authorization code for
* tokens.
*/
access_type
?:
string
/**
* The hd (hosted domain) parameter streamlines the login process for G Suite
* hosted accounts. By including the domain of the G Suite user (for example,
* mycollege.edu), you can indicate that the account selection UI should be
* optimized for accounts at that domain. To optimize for G Suite accounts
* generally instead of just one domain, use an asterisk: hd=*.
* Don't rely on this UI optimization to control who can access your app,
* as client-side requests can be modified. Be sure to validate that the
* returned ID token has an hd claim value that matches what you expect
* (e.g. mycolledge.edu). Unlike the request parameter, the ID token claim is
* contained within a security token from Google, so the value can be trusted.
*/
hd
?:
string
/**
* The 'response_type' will always be set to 'CODE'.
*/
response_type
?:
string
/**
* The client ID for your application. The value passed into the constructor
* will be used if not provided. You can find this value in the API Console.
*/
client_id
?:
string
/**
* Determines where the API server redirects the user after the user
* completes the authorization flow. The value must exactly match one of the
* 'redirect_uri' values listed for your project in the API Console. Note that
* the http or https scheme, case, and trailing slash ('/') must all match.
* The value passed into the constructor will be used if not provided.
*/
redirect_uri
?:
string
/**
* Required. A space-delimited list of scopes that identify the resources that
* your application could access on the user's behalf. These values inform the
* consent screen that Google displays to the user. Scopes enable your
* application to only request access to the resources that it needs while
* also enabling users to control the amount of access that they grant to your
* application. Thus, there is an inverse relationship between the number of
* scopes requested and the likelihood of obtaining user consent. The
* OAuth 2.0 API Scopes document provides a full list of scopes that you might
* use to access Google APIs. We recommend that your application request
* access to authorization scopes in context whenever possible. By requesting
* access to user data in context, via incremental authorization, you help
* users to more easily understand why your application needs the access it is
* requesting.
*/
scope
?:
string
[]
|
string
/**
* Recommended. Specifies any string value that your application uses to
* maintain state between your authorization request and the authorization
* server's response. The server returns the exact value that you send as a
* name=value pair in the hash (#) fragment of the 'redirect_uri' after the
* user consents to or denies your application's access request. You can use
* this parameter for several purposes, such as directing the user to the
* correct resource in your application, sending nonces, and mitigating
* cross-site request forgery. Since your redirect_uri can be guessed, using a
* state value can increase your assurance that an incoming connection is the
* result of an authentication request. If you generate a random string or
* encode the hash of a cookie or another value that captures the client's
* state, you can validate the response to additionally ensure that the
* request and response originated in the same browser, providing protection
* against attacks such as cross-site request forgery. See the OpenID Connect
* documentation for an example of how to create and confirm a state token.
*/
state
?:
string
/**
* Optional. Enables applications to use incremental authorization to request
* access to additional scopes in context. If you set this parameter's value
* to true and the authorization request is granted, then the new access token
* will also cover any scopes to which the user previously granted the
* application access. See the incremental authorization section for examples.
*/
include_granted_scopes
?:
boolean
/**
* Optional. If your application knows which user is trying to authenticate,
* it can use this parameter to provide a hint to the Google Authentication
* Server. The server uses the hint to simplify the login flow either by
* prefilling the email field in the sign-in form or by selecting the
* appropriate multi-login session. Set the parameter value to an email
* address or sub identifier, which is equivalent to the user's Google ID.
*/
login_hint
?:
string
/**
* Optional. A space-delimited, case-sensitive list of prompts to present the
* user. If you don't specify this parameter, the user will be prompted only
* the first time your app requests access. Possible values are:
*
* 'none' - Donot display any authentication or consent screens. Must not be
* specified with other values.
* 'consent' - Prompt the user for consent.
* 'select_account' - Prompt the user to select an account.
*/
prompt
?:
string
}
src/lib/types/news-detail-response-data.ts
deleted
100644 → 0
View file @
d3f4f2a6
export
interface
NewsDetailItem
{
id
:
string
title
:
string
thumbnail
:
string
description
:
string
release_at
:
string
is_active
:
boolean
created_at
:
string
created_by
:
string
|
null
updated_at
:
string
updated_by
:
string
|
null
mode
:
'NOW'
|
string
category
:
string
}
export
interface
NewsDetailResponseData
{
count
:
number
rows
:
NewsDetailItem
[]
totalPages
:
number
currentPage
:
number
}
export
interface
GetNewsDetailResponseType
{
message
:
string
message_en
:
string
responseData
:
NewsDetailItem
status
:
'success'
|
'error'
timeStamp
:
string
violations
:
any
|
null
}
\ No newline at end of file
src/lib/types/search-params.ts
deleted
100644 → 0
View file @
d3f4f2a6
export
interface
SearchParams
<
Key
extends
string
=
string
>
extends
URLSearchParams
{
get
:
(
name
:
Key
)
=>
string
|
null
}
src/lib/utils/cn.ts
deleted
100644 → 0
View file @
d3f4f2a6
// Core
import
{
twMerge
}
from
'tailwind-merge'
import
{
clsx
,
type
ClassValue
}
from
'clsx'
// Cn
export
function
cn
(...
inputs
:
ClassValue
[])
{
return
twMerge
(
clsx
(
inputs
))
}
src/lib/utils/crypto.ts
deleted
100644 → 0
View file @
d3f4f2a6
import
{
Buffer
}
from
'buffer'
export
async
function
encrypt
(
dataToEncrypt
:
string
,
keyString
:
string
,
bufferString
:
string
)
{
const
ivBuffer
=
Buffer
.
from
(
bufferString
,
'base64'
)
const
key
=
await
getKey
(
keyString
)
const
dataBuffer
=
new
TextEncoder
().
encode
(
dataToEncrypt
)
const
encryptedData
=
await
encryptData
(
dataBuffer
,
key
,
ivBuffer
)
return
Buffer
.
from
(
encryptedData
).
toString
(
'base64'
)
}
export
async
function
decrypt
(
encryptedDataString
:
string
,
keyString
:
string
,
bufferString
:
string
)
{
const
key
=
await
getKey
(
keyString
)
const
encryptedBuffer
=
Buffer
.
from
(
encryptedDataString
,
'base64'
)
const
ivBuffer
=
Buffer
.
from
(
bufferString
,
'base64'
)
const
decryptedBuffer
=
await
decryptData
(
encryptedBuffer
,
key
,
ivBuffer
)
return
new
TextDecoder
().
decode
(
decryptedBuffer
)
}
// ======================================= HELPER FUNCTIONS ================================
const
getKey
=
async
(
encryptionKey
:
string
)
=>
{
return
await
crypto
.
subtle
.
importKey
(
'raw'
,
Buffer
.
from
(
encryptionKey
),
{
name
:
'AES-GCM'
,
length
:
256
},
true
,
[
'encrypt'
,
'decrypt'
]
)
}
const
encryptData
=
async
(
dataBuffer
:
BufferSource
,
key
:
CryptoKey
,
ivBuffer
:
BufferSource
)
=>
{
return
await
crypto
.
subtle
.
encrypt
(
{
name
:
'AES-GCM'
,
iv
:
ivBuffer
},
key
,
dataBuffer
)
}
const
decryptData
=
async
(
encryptedDataBuffer
:
BufferSource
,
key
:
CryptoKey
,
ivBuffer
:
BufferSource
)
=>
{
return
await
crypto
.
subtle
.
decrypt
(
{
name
:
'AES-GCM'
,
iv
:
ivBuffer
},
key
,
encryptedDataBuffer
)
}
src/lib/utils/env-config.ts
deleted
100644 → 0
View file @
d3f4f2a6
// import { z } from 'zod'
// const envConfigSchema = z.object({
// BUFFER_KEY: z.string().min(1),
// SECRET_KEY: z.string().min(1)
// })
// Process env is for nextjs, import meta for vite
// const envConfigParser = envConfigSchema.safeParse({
// BUFFER_KEY: typeof process != 'undefined' ? process.env.FOSCO_BUFFER : import.meta.env.FOSCO_BUFFER,
// SECRET_KEY: typeof process != 'undefined' ? process.env.FOSCO_SECRET : import.meta.env.FOSCO_SECRET
// })
// if (!envConfigParser.success) {
// console.error(envConfigParser.error.issues)
// throw new Error('Invalid .env variable values')
// }
// export const envConfig = envConfigParser.data
// export default envConfig
src/lib/utils/error.ts
deleted
100644 → 0
View file @
d3f4f2a6
// Core
// import { isAxiosError } from 'axios'
// App
// import i18n, { Language } from './i18n'
// Utils
// Extract error message
// export const extractErrorMessage = (error: unknown) => {
// const axiosErrorResponse = isAxiosError(error) ? error.response : null
// const errorMessage =
// i18n.language === Language.Vi
// ? (axiosErrorResponse?.data.message ?? 'Có lỗi xảy ra, vui lòng thử lại sau')
// : (axiosErrorResponse?.data.message_en ?? 'An error occurred, please try again later')
// return errorMessage
// }
src/lib/utils/generate-slug.ts
deleted
100644 → 0
View file @
d3f4f2a6
// import slugify from 'slugify'
// export const generateSlug = (text: string) => {
// return slugify(text, {
// replacement: '-',
// lower: true,
// locale: 'vi',
// trim: true,
// strict: true
// })
// }
src/lib/utils/post.ts
0 → 100644
View file @
ef2f7367
import
Links
from
"@/links"
;
/**
* Helpers dùng chung cho các component hiển thị post (home, sections...).
* Toàn bộ là hàm thuần — an toàn khi dùng ở cả Server Components và client.
*/
/** Chuẩn hoá link: giữ http(s)/path, tự thêm "/" nếu thiếu, fallback nếu rỗng. */
export
const
normalizeLink
=
(
value
?:
string
|
null
,
fallback
=
"/"
)
=>
{
const
trimmed
=
value
?.
trim
();
if
(
!
trimmed
)
return
fallback
;
if
(
/^https
?
:
\/\/
/i
.
test
(
trimmed
)
||
trimmed
.
startsWith
(
"/"
))
return
trimmed
;
return
`/
${
trimmed
}
`
;
};
/** Link chi tiết bài viết: slug/title + ?id=<postId> nếu có id. */
export
const
buildPostLink
=
(
path
?:
string
|
null
,
id
?:
string
|
null
,
fallback
=
"/"
,
)
=>
{
const
normalizedPath
=
normalizeLink
(
path
,
fallback
);
const
trimmedId
=
id
?.
trim
()
??
""
;
if
(
!
trimmedId
||
normalizedPath
===
"#"
)
return
normalizedPath
;
const
params
=
new
URLSearchParams
({
id
:
trimmedId
});
return
`
${
normalizedPath
}
?
${
params
.
toString
()}
`
;
};
/** Resolve URL asset (thumbnail...) qua Links.resolveImageUrl, fallback mặc định. */
export
const
resolveAssetUrl
=
(
value
?:
string
|
null
)
=>
{
const
trimmed
=
value
?.
trim
();
if
(
!
trimmed
)
return
"/thumbnail.png"
;
return
Links
.
resolveImageUrl
(
trimmed
);
};
/** Bỏ caption/figure/img/tags + entities, trả text thuần dùng cho excerpt. */
export
const
stripHtml
=
(
raw
?:
string
|
null
)
=>
(
raw
??
""
)
.
replace
(
/
\[
caption
[^\]]
*
\]
.*
?\[\/
caption
\]
/gi
,
""
)
.
replace
(
/<figure
[^
>
]
*>.*
?
<
\/
figure>/gi
,
""
)
.
replace
(
/<img
[^
>
]
*>/gi
,
""
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/ /g
,
" "
)
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
"'"
)
.
replace
(
/–/g
,
"–"
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
src/lib/utils/storage.ts
deleted
100644 → 0
View file @
d3f4f2a6
// // Core
// import { decrypt, encrypt } from './crypto'
// import { PersistStorage, StorageValue } from 'zustand/middleware'
// import { getCookie, removeCookie, setCookie } from 'typescript-cookie'
// // App
// import envConfig from './env-config'
// export async function getItem(name: string) {
// const cipherText = getCookie(name)
// // const cipherText = localStorage.getItem(name)
// if (!cipherText) return null
// const originalText = await decrypt(cipherText, envConfig.SECRET_KEY, envConfig.BUFFER_KEY)
// if (!originalText) return null
// return JSON.parse(originalText)
// }
// export async function setItem<S>(name: string, value: StorageValue<S>) {
// const cipherText = await encrypt(JSON.stringify(value), envConfig.SECRET_KEY, envConfig.BUFFER_KEY)
// setCookie(name, cipherText)
// localStorage.setItem(name, cipherText)
// }
// export async function removeItem(name: string) {
// removeCookie(name)
// localStorage.removeItem(name)
// }
// // Utils
// // Create storage
// export const createStorage = <S>(): PersistStorage<S> => {
// return {
// getItem,
// setItem,
// removeItem
// }
// }
src/mockdata/home-posts.ts
View file @
ef2f7367
/**
* Type local — được khai báo trực tiếp ở đây để tránh vấn đề Next.js không resolve
* được module qua route group lồng `(main)/(home)`.
Shape này được giữ đồng bộ
*
với `HomePostItem` / `HomePostCategory` trong `use-home-posts.ts`
.
* được module qua route group lồng `(main)/(home)`.
Các component home tự định nghĩa
*
type mapper riêng và map shape của mock data này sang type của chúng
.
*/
export
type
HomePostCategory
=
{
id
:
string
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment