Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
VCCI-News
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
Commits
ddc3618f
Commit
ddc3618f
authored
Nov 12, 2025
by
Văn Hoàng
Browse files
Options
Browse Files
Download
Plain Diff
[tag]0.1-vcci
parents
e57f97aa
eb26c4cf
Pipeline
#43907
passed with stages
in 3 minutes and 34 seconds
Changes
4
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
309 additions
and
90 deletions
+309
-90
index.tsx
src/app/(main)/[...slug]/components/event-detail/index.tsx
+0
-38
index.tsx
src/app/(main)/[...slug]/components/news-detail/index.tsx
+0
-24
page.tsx
src/app/(main)/[...slug]/page.tsx
+258
-28
index.tsx
src/components/base/card-events/index.tsx
+51
-0
No files found.
src/app/(main)/[...slug]/components/event-detail/index.tsx
deleted
100644 → 0
View file @
e57f97aa
"use client"
;
import
parse
from
"html-react-parser"
;
import
dayjs
from
"dayjs"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
{
useGetNewsId
}
from
"@/api/endpoints/news"
;
import
{
GetNewsDetailResponseType
}
from
"./../../page.type"
;
interface
EventDetailProps
{
id
?:
string
;
}
export
default
function
EventDetail
({
id
}:
EventDetailProps
)
{
if
(
!
id
)
return
null
;
const
{
data
:
eventDetail
,
isLoading
}
=
useGetNewsId
<
GetNewsDetailResponseType
>
(
id
);
if
(
isLoading
)
{
return
(
<
div
className=
"flex justify-center py-6"
>
<
Spinner
/>
</
div
>
);
}
const
event
=
eventDetail
?.
responseData
;
if
(
!
event
)
return
null
;
return
(
<
div
>
<
h1
className=
"text-2xl font-medium text-primary"
>
{
event
.
title
}
</
h1
>
<
div
className=
"text-sm text-blue-700 mb-4"
>
{
dayjs
(
event
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
</
div
>
<
div
className=
"prose tiptap"
>
{
parse
(
event
.
description
??
""
)
}
</
div
>
</
div
>
);
}
src/app/(main)/[...slug]/components/news-detail/index.tsx
deleted
100644 → 0
View file @
e57f97aa
"use client"
;
import
parse
from
"html-react-parser"
;
import
dayjs
from
"dayjs"
;
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
interface
NewsDetailProps
{
data
:
GetNewsResponseType
;
}
export
default
function
NewsDetail
({
data
}:
NewsDetailProps
)
{
const
news
=
data
?.
responseData
?.
rows
?.[
0
];
if
(
!
news
)
return
null
;
return
(
<
div
>
<
h1
className=
"text-2xl font-medium text-primary"
>
{
news
.
title
}
</
h1
>
<
div
className=
"text-sm text-blue-700 mb-4"
>
{
dayjs
(
news
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
</
div
>
<
div
className=
"prose tiptap"
>
{
parse
(
news
.
description
??
""
)
}
</
div
>
</
div
>
);
}
src/app/(main)/[...slug]/page.tsx
View file @
ddc3618f
...
...
@@ -8,68 +8,271 @@ import { ListFilter } from "@/components/base/list-filter";
import
EventCalendar
from
"@/components/base/event-calendar"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
CardNews
from
"@/components/base/card-news"
;
import
Image
from
"next/image"
;
import
parse
from
"html-react-parser"
;
import
dayjs
from
"dayjs"
;
import
BASE_URL
from
"@/links/index"
;
// API hooks
import
{
useGetNews
}
from
"@/api/endpoints/news"
;
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
import
{
GetNewsDetailResponseType
}
from
"./page.type"
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
"@/api/endpoints/news-page-config"
;
import
{
GetNewsPageConfigResponseType
}
from
"@/api/types/news-page-config"
;
// Component con
import
NewsDetail
from
"./components/news-detail
"
;
import
EventDetail
from
"./components/event-detail
"
;
import
{
useGetEvents
}
from
"@/api/endpoints/event"
;
import
{
EventApiResponse
}
from
"@/api/types/event"
;
import
CardEvents
from
"@/components/base/card-events
"
;
import
{
Calendar
,
CreditCard
,
MapPin
}
from
"lucide-react
"
;
export
default
function
DynamicPage
()
{
// get url
const
params
=
useParams
();
const
slugArray
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
const
lastPart
=
slugArray
[
slugArray
.
length
-
1
];
const
url
=
slugArray
.
join
(
"/"
);
const
isUUID
=
/^
[
0-9a-fA-F
]{8}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{12}
$/
.
test
(
lastPart
as
string
);
const
id
=
isUUID
?
lastPart
:
undefined
;
// states
const
[
submitSearch
,
setSubmitSearch
]
=
useState
(
""
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
pageSize
=
5
;
// quer
ies
// quer
y
const
{
data
:
categoriesPage
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
code
:
slugArray
[
0
],
code
:
`
${
slugArray
[
0
]}
`
,
});
const
{
data
:
events
,
isLoading
:
isLoadingEvents
}
=
useGetEvents
<
EventApiResponse
>
({
pageSize
:
String
(
pageSize
),
currentPage
:
String
(
page
),
});
const
{
data
:
newsDetail
,
isLoading
:
isLoadingDetail
}
=
useGetNews
<
GetNewsResponseTyp
e
>
({
filters
:
`
page_config.static_link==/
${
url
}
,external_link@=
${
lastPart
}
`
,
const
{
data
:
eventsDetail
,
isLoading
:
isLoadingEventsDetail
}
=
useGetEvents
<
EventApiRespons
e
>
({
filters
:
`
id==
${
id
}
`
,
});
const
{
data
:
news
,
isLoading
}
=
useGetNews
<
GetNewsResponseType
>
({
const
{
data
:
news
,
isLoading
:
isLoadingNews
}
=
useGetNews
<
GetNewsResponseType
>
({
pageSize
:
String
(
pageSize
),
currentPage
:
String
(
page
),
filters
:
`page_config.static_link==/
${
url
}
${
submitSearch
?
`,title@=
${
submitSearch
}
`
:
""
}
`
,
filters
:
`page_config.static_link==/
${
url
}
`
+
(
submitSearch
?
`,title@=
${
submitSearch
}
`
:
""
)
,
});
if
(
isLoadingDetail
)
{
const
{
data
:
newsDetail
,
isLoading
:
isLoadingNewsDetail
}
=
useGetNews
<
GetNewsResponseType
>
({
filters
:
`page_config.static_link==/
${
url
}
`
+
`,external_link@=
${
lastPart
}
`
,
});
// event page
const
isEventPage
=
lastPart
===
"su-kien"
;
if
(
isEventPage
)
{
return
(
<
div
className=
"min-h-screen container mx-auto"
>
<
div
className=
"w-full flex flex-col gap-5"
>
<
ListCategory
categories=
{
categoriesPage
?.
responseData
?.
children
}
/>
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
main
className=
"lg:col-span-2 bg-background"
>
<
div
className=
"pb-5 overflow-hidden"
>
{
isLoadingEvents
?
(
<
div
className=
"flex justify-center items-center py-12"
>
<
Spinner
className=
"size-8"
/>
<
span
className=
"ml-2 text-gray-600"
>
Đang tải tin VCCI...
</
span
>
</
div
>
)
:
events
?.
responseData
.
rows
.
length
===
0
?
(
<
p
className=
"text-center py-4"
>
Không có dữ liệu
</
p
>
)
:
(
<>
{
events
?.
responseData
.
rows
.
map
((
item
)
=>
(
<
CardEvents
key=
{
item
.
id
}
event=
{
item
}
link=
{
`su-kien/${item.id}`
}
/>
))
}
<
div
className=
"w-full flex justify-center mt-4"
>
<
Pagination
pageCount=
{
Number
(
events
?.
responseData
.
totalPages
??
1
)
}
page=
{
Number
(
events
?.
responseData
.
currentPage
??
page
)
}
onChangePage=
{
setPage
}
onGoToPreviousPage=
{
()
=>
setPage
(
Math
.
max
(
1
,
page
-
1
))
}
onGoToNextPage=
{
()
=>
setPage
(
Math
.
min
(
Number
(
events
?.
responseData
.
totalPages
??
1
),
page
+
1
))
}
/>
</
div
>
</>
)
}
</
div
>
</
main
>
<
aside
className=
"space-y-6"
>
<
ListFilter
onSearch=
{
setSubmitSearch
}
/>
<
EventCalendar
/>
</
aside
>
</
div
>
</
div
>
</
div
>
)
};
// detail event page
if
(
eventsDetail
?.
responseData
.
rows
.
length
===
1
)
{
return
(
<
div
className=
"flex justify-center py-12"
>
<
Spinner
/>
<
div
className=
"min-h-screen w-full container mx-auto p-4"
>
<
div
className=
"w-full flex flex-col gap-5"
>
<
ListCategory
categories=
{
categoriesPage
?.
responseData
?.
children
}
/>
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
{
/* Main content */
}
<
main
className=
"lg:col-span-2 bg-white border rounded-md p-6"
>
{
isLoadingEventsDetail
?
(
<
div
className=
"flex justify-center items-center py-12"
>
<
Spinner
className=
"size-8"
/>
<
span
className=
"ml-2 text-gray-600"
>
Đang tải chi tiết sự kiện...
</
span
>
</
div
>
)
:
(
<>
<
div
className=
"pb-5 text-primary text-2xl leading-normal font-medium"
>
{
eventsDetail
?.
responseData
?.
rows
[
0
].
name
}
</
div
>
<
hr
className=
"py-2"
/>
{
/* Top summary with image + details */
}
<
div
className=
"flex flex-col lg:flex-row gap-6 my-6"
>
<
div
className=
"w-full lg:w-1/2 bg-gray-50 rounded-md overflow-hidden"
>
{
eventsDetail
?.
responseData
?.
rows
[
0
].
image
?
(
<
div
className=
"w-full h-52 relative "
>
<
EventImage
src=
{
`${BASE_URL.imageEndpoint}${eventsDetail.responseData.rows[0].image}`
}
alt=
{
eventsDetail
.
responseData
.
rows
[
0
].
name
||
"image"
}
/>
</
div
>
)
:
(
<
div
className=
"w-full h-52 bg-gray-200"
/>
)
}
</
div
>
<
div
className=
"w-full lg:w-1/2 bg-white border rounded-md p-6"
>
<
div
className=
"flex flex-col gap-3"
>
<
div
className=
"text-sm text-gray-500"
>
Hạn đăng kí:
{
" "
}
<
span
className=
"text-gray-900 font-medium"
>
{
eventsDetail
.
responseData
.
rows
[
0
].
created_at
?
dayjs
(
eventsDetail
.
responseData
.
rows
[
0
].
created_at
).
format
(
'DD/MM/YYYY'
)
:
"-"
}
</
span
>
</
div
>
<
div
className=
"text-sm text-gray-500 flex items-start gap-2"
>
<
Calendar
className=
"h-5 w-5 text-yellow-500"
/>
<
div
>
<
div
className=
"text-sm font-medium text-gray-800"
>
Bắt đầu:
{
eventsDetail
?.
responseData
?.
rows
[
0
].
start_time
?
dayjs
(
eventsDetail
.
responseData
.
rows
[
0
].
start_time
).
format
(
'HH:mm DD/MM/YYYY'
)
:
"-"
}
</
div
>
<
div
className=
"text-sm font-medium text-gray-800"
>
Kết thúc:
{
eventsDetail
?.
responseData
?.
rows
[
0
].
end_time
?
dayjs
(
eventsDetail
.
responseData
.
rows
[
0
].
end_time
).
format
(
'HH:mm DD/MM/YYYY'
)
:
"-"
}
</
div
>
</
div
>
</
div
>
<
div
className=
"text-sm text-gray-500 flex items-center gap-2"
>
<
MapPin
className=
"h-5 w-5 text-blue-600"
/>
<
div
className=
"text-sm font-medium text-gray-800"
>
Địa điểm:
{
eventsDetail
?.
responseData
?.
rows
[
0
].
location
??
eventsDetail
?.
responseData
?.
rows
[
0
].
province
??
"-"
}
</
div
>
</
div
>
<
div
className=
"text-sm text-gray-500 flex items-center gap-2"
>
<
CreditCard
className=
"h-5 w-5 text-yellow-400"
/>
<
div
className=
"text-sm font-medium text-gray-800"
>
Phí tham dự:
{
eventsDetail
?.
responseData
?.
rows
[
0
].
table_cost
?
`${eventsDetail.responseData.rows[0].table_count
} Bàn : ${eventsDetail.responseData.rows[0].table_cost.toLocaleString()} đ`
:
"Vui lòng xem chi tiết trong bài"
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Full description */
}
<
div
className=
"p-7.5 prose tiptap overflow-hidden"
>
{
parse
(
eventsDetail
?.
responseData
?.
rows
[
0
].
description
??
""
)
}
</
div
>
</>
)
}
</
main
>
{
/* Sidebar */
}
<
aside
className=
"space-y-6"
>
<
EventCalendar
/>
<
div
className=
"bg-white border rounded-md overflow-hidden"
>
<
div
className=
"w-full h-56 relative bg-gray-100"
>
<
Image
src=
"/banner.webp"
alt=
"Quảng cáo"
fill
className=
"object-cover"
/>
</
div
>
</
div
>
</
aside
>
</
div
>
</
div
>
</
div
>
);
}
// check UUID
const
isUUID
=
/^
[
0-9a-fA-F
]{8}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{4}
-
[
0-9a-fA-F
]{12}
$/
.
test
(
lastPart
as
string
);
const
id
=
isUUID
?
lastPart
:
undefined
;
// detail page condition
// detail news page
const
isDetailPage
=
newsDetail
?.
responseData
?.
rows
?.
length
===
1
;
if
(
isDetailPage
||
id
)
{
if
(
isDetailPage
)
{
return
(
<
div
className=
"container w-full flex justify-center items-center pb-10"
>
<
div
className=
"flex flex-col gap-5 w-full"
>
<
div
className=
'container w-full flex justify-center items-center pb-10'
>
<
div
className=
'flex flex-col gap-5 w-full'
>
<
ListCategory
categories=
{
categoriesPage
?.
responseData
?.
children
}
/>
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-5"
>
<
main
className=
"lg:col-span-2 bg-white border rounded-md p-8"
>
{
isDetailPage
?
<
NewsDetail
data=
{
newsDetail
}
/>
:
<
EventDetail
id=
{
id
}
/>
}
{
isLoadingNewsDetail
?
(
<
div
className=
"flex justify-center items-center py-12"
>
<
Spinner
className=
"size-8"
/>
<
span
className=
"ml-2 text-gray-600"
>
Đang tải tin VCCI...
</
span
>
</
div
>
)
:
newsDetail
?.
responseData
?.
rows
.
length
===
0
?
(
<
p
className=
"text-center py-4"
>
Không có dữ liệu
</
p
>
)
:
(
<>
<
div
className=
'pb-5 text-primary text-2xl leading-normal font-medium'
>
{
newsDetail
?.
responseData
?.
rows
[
0
].
title
}
</
div
>
<
div
className=
'flex items-center gap-2 text-sm mb-4'
>
<
span
className=
'text-base text-blue-700'
>
{
dayjs
(
newsDetail
?.
responseData
?.
rows
[
0
].
created_at
).
format
(
'DD/MM/YYYY'
)
}
</
span
>
</
div
>
<
hr
className=
"my-5"
/>
<
div
className=
'flex-1 text-app-grey text-base overflow-hidden'
>
<
div
className=
"prose tiptap overflow-hidden"
>
{
parse
(
newsDetail
?.
responseData
?.
rows
[
0
].
description
??
''
)
}
</
div
>
</
div
>
</>
)
}
</
main
>
<
aside
className=
"space-y-6"
>
<
EventCalendar
/>
...
...
@@ -80,7 +283,7 @@ export default function DynamicPage() {
);
}
//
list
news page
// news page
return
(
<
div
className=
"min-h-screen container mx-auto"
>
<
div
className=
"w-full flex flex-col gap-5"
>
...
...
@@ -88,7 +291,7 @@ export default function DynamicPage() {
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
main
className=
"lg:col-span-2 bg-background"
>
<
div
className=
"pb-5 overflow-hidden"
>
{
isLoading
?
(
{
isLoading
News
?
(
<
div
className=
"flex justify-center items-center py-12"
>
<
Spinner
className=
"size-8"
/>
<
span
className=
"ml-2 text-gray-600"
>
Đang tải tin VCCI...
</
span
>
...
...
@@ -98,7 +301,11 @@ export default function DynamicPage() {
)
:
(
<>
{
news
?.
responseData
.
rows
.
map
((
item
)
=>
(
<
CardNews
key=
{
item
.
id
}
news=
{
item
}
link=
{
`${item.external_link}`
}
/>
<
CardNews
key=
{
item
.
id
}
news=
{
item
}
link=
{
`${item.external_link}`
}
/>
))
}
<
div
className=
"w-full flex justify-center mt-4"
>
<
Pagination
...
...
@@ -127,5 +334,28 @@ export default function DynamicPage() {
</
div
>
</
div
>
</
div
>
)
}
// Local small component to safely handle Image src fallback without mutating DOM
type
EventImageProps
=
{
src
:
string
;
alt
?:
string
;
};
function
EventImage
({
src
,
alt
}:
EventImageProps
)
{
const
[
imgSrc
,
setImgSrc
]
=
useState
<
string
>
(
src
);
return
(
<
Image
src=
{
imgSrc
}
alt=
{
alt
??
"image"
}
fill
className=
"object-cover"
onError=
{
()
=>
{
// swap to local fallback file when Next/Image fails to load the provided URL
if
(
imgSrc
!==
"/img-error.png"
)
setImgSrc
(
"/img-error.png"
);
}
}
/>
);
}
src/components/base/card-events/index.tsx
0 → 100644
View file @
ddc3618f
import
{
EventItem
}
from
'@/api/types/event'
;
import
Links
from
'@links/index'
import
dayjs
from
'dayjs'
;
// Helper: remove <img> tags and extract plain text from HTML
const
stripImagesAndHtml
=
(
html
?:
string
)
=>
{
if
(
!
html
)
return
''
// remove img tags first
const
withoutImgs
=
html
.
replace
(
/<img
[^
>
]
*>/gi
,
''
)
// use DOMParser on client for robust extraction
if
(
typeof
window
!==
'undefined'
&&
typeof
DOMParser
!==
'undefined'
)
{
try
{
const
doc
=
new
DOMParser
().
parseFromString
(
withoutImgs
,
'text/html'
)
return
doc
.
body
.
textContent
||
''
}
catch
{
// fallback to regex
}
}
return
withoutImgs
.
replace
(
/<
[^
>
]
*>/g
,
''
)
}
const
CardEvents
=
({
event
,
link
}:
{
event
:
EventItem
,
link
:
string
})
=>
{
return
(
<
a
href=
{
`${link}`
}
className=
"flex flex-col hover:no-underline sm:flex-row gap-2 mb-6 bg-white rounded-lg shadow-sm p-4 border items-start min-w-0"
>
<
img
src=
{
`${Links.imageEndpoint}${event.image}`
}
alt=
{
event
.
name
}
className=
"w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
onError=
{
(
e
)
=>
{
e
.
currentTarget
.
src
=
"/img-error.png"
}
}
/>
<
div
className=
"flex-1 min-w-0 pl-0 sm:pl-4"
>
<
p
className=
"text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2 wrap-break-word"
>
{
event
.
name
}
</
p
>
<
div
className=
"text-sm my-2 text-[#00AED5]"
>
{
dayjs
(
event
.
start_time
).
format
(
'DD/MM/YYYY'
)
}
</
div
>
<
div
className=
"text-sm text-[#777] line-clamp-3"
>
<
div
className=
"text-sm prose tiptap"
>
{
stripImagesAndHtml
(
event
.
description
)
}
</
div
>
</
div
>
</
div
>
</
a
>
)
}
export
default
CardEvents
;
\ No newline at end of file
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