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
4be77472
You need to sign in or sign up before continuing.
Commit
4be77472
authored
Nov 12, 2025
by
Phạm Quang Bảo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
bae5e371
Changes
4
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 @
bae5e371
"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 @
bae5e371
"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 @
4be77472
...
...
@@ -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 @
4be77472
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