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
2902be94
Commit
2902be94
authored
May 19, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
adfed5ce
Changes
15
Show whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
323 additions
and
148 deletions
+323
-148
custom-client.ts
src/api/mutator/custom-client.ts
+11
-3
use-home-posts.ts
src/app/(main)/(home)/lib/use-home-posts.ts
+9
-52
ArticleDetailPage.tsx
src/app/(main)/[...slug]/templates/ArticleDetailPage.tsx
+16
-11
ArticlePage.tsx
src/app/(main)/[...slug]/templates/ArticlePage.tsx
+14
-7
CatalogPage.tsx
src/app/(main)/[...slug]/templates/CatalogPage.tsx
+13
-9
InformationPage.tsx
src/app/(main)/[...slug]/templates/InformationPage.tsx
+12
-8
StructuredPostContent.tsx
src/app/(main)/[...slug]/templates/StructuredPostContent.tsx
+80
-0
data.ts
src/app/(main)/[...slug]/templates/data.ts
+42
-0
types.ts
src/app/(main)/[...slug]/templates/types.ts
+13
-0
header.tsx
src/app/(main)/_lib/layout/header.tsx
+72
-44
page.tsx
src/app/(main)/search/page.tsx
+5
-5
page.tsx
src/app/(main)/video/page.tsx
+2
-2
index.tsx
src/components/base/list-category/index.tsx
+27
-4
index.tsx
src/components/base/menu-category/index.tsx
+2
-2
index.ts
src/links/index.ts
+5
-1
No files found.
src/api/mutator/custom-client.ts
View file @
2902be94
...
...
@@ -12,14 +12,16 @@ interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig {
const
createAxiosInstance
=
()
=>
{
const
instance
=
Axios
.
create
({
baseURL
:
links
.
apiEndpoint
,
withCredentials
:
tru
e
,
withCredentials
:
fals
e
,
});
instance
.
interceptors
.
request
.
use
(
async
(
config
)
=>
{
if
(
shouldSkipAuthHandling
(
config
.
url
))
{
if
(
shouldSkipAuthHandling
(
config
.
url
)
||
!
shouldHandleAdminAuth
())
{
config
.
withCredentials
=
false
;
return
config
;
}
config
.
withCredentials
=
true
;
const
token
=
await
ensureValidAdminAccessToken
().
catch
(()
=>
null
);
if
(
token
)
{
...
...
@@ -40,7 +42,8 @@ const createAxiosInstance = () => {
error
.
response
?.
status
!==
401
||
!
originalRequest
||
originalRequest
.
_retry
||
shouldSkipAuthHandling
(
originalRequest
.
url
)
shouldSkipAuthHandling
(
originalRequest
.
url
)
||
!
shouldHandleAdminAuth
()
)
{
return
Promise
.
reject
(
error
);
}
...
...
@@ -89,6 +92,11 @@ const shouldSkipAuthHandling = (url?: string | null) => {
return
/
\/
auth
\/(
login|refresh|logout
)(\?
|$
)
/
.
test
(
url
);
};
const
shouldHandleAdminAuth
=
()
=>
{
if
(
typeof
window
===
"undefined"
)
return
false
;
return
window
.
location
.
pathname
.
startsWith
(
"/admin"
);
};
const
convertHeaders
=
(
headers
?:
HeadersInit
):
Record
<
string
,
string
>
|
undefined
=>
{
if
(
!
headers
)
return
undefined
;
...
...
src/app/(main)/(home)/lib/use-home-posts.ts
View file @
2902be94
...
...
@@ -135,6 +135,9 @@ const HOME_CATEGORY_IDS = {
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
;
...
...
@@ -232,33 +235,6 @@ async function fetchHomePostRows(path: string) {
return
response
.
responseData
?.
rows
??
[];
}
async
function
fetchHomeCategoryRows
()
{
const
response
=
await
useCustomClient
<
HomeEnvelope
<
HomePagedResult
<
RawHomeCategory
>>>
(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC"
,
);
return
response
.
responseData
?.
rows
??
[];
}
function
findCategoryIdByAliases
(
categories
:
RawHomeCategory
[],
aliases
:
readonly
string
[],
)
{
const
aliasKeys
=
new
Set
(
aliases
.
map
(
normalizeSearchText
));
const
aliasSlugs
=
new
Set
(
aliases
.
map
(
normalizeSlug
));
return
categories
.
find
((
category
)
=>
{
const
categoryNameKey
=
normalizeSearchText
(
category
.
name
);
const
categorySlugKey
=
normalizeSlug
(
category
.
slug
||
category
.
name
);
const
categoryUrlKey
=
normalizeSlug
(
category
.
url
);
return
(
aliasKeys
.
has
(
categoryNameKey
)
||
aliasSlugs
.
has
(
categorySlugKey
)
||
Array
.
from
(
aliasSlugs
).
some
((
slug
)
=>
categoryUrlKey
.
endsWith
(
slug
))
);
})?.
id
??
null
;
}
function
createCategoryPostsQuery
(
categoryId
:
string
,
pageSize
:
string
)
{
return
new
URLSearchParams
({
page
:
"1"
,
...
...
@@ -276,19 +252,6 @@ function createCategoryPostsQuery(categoryId: string, pageSize: string) {
}
async
function
fetchHomePosts
()
{
const
categoryRows
=
await
fetchHomeCategoryRows
().
catch
(()
=>
[]);
const
trainingCategoryId
=
findCategoryIdByAliases
(
categoryRows
,
HOME_CATEGORY_ALIASES
.
daoTao
,
);
const
businessCategoryId
=
findCategoryIdByAliases
(
categoryRows
,
HOME_CATEGORY_ALIASES
.
coHoiKinhDoanh
,
);
const
memberConnectionCategoryId
=
findCategoryIdByAliases
(
categoryRows
,
HOME_CATEGORY_ALIASES
.
ketNoiHoiVien
,
);
const
featuredQuery
=
new
URLSearchParams
({
page
:
"1"
,
pageSize
:
"10"
,
...
...
@@ -308,15 +271,9 @@ async function fetchHomePosts() {
const
eventQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
suKien
,
"5"
);
const
policyQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
chinhSachPhapLuat
,
"6"
);
const
quickLinksQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
lienKetNhanh
,
"6"
);
const
trainingQuery
=
trainingCategoryId
?
createCategoryPostsQuery
(
String
(
trainingCategoryId
),
"10"
)
:
null
;
const
businessQuery
=
businessCategoryId
?
createCategoryPostsQuery
(
String
(
businessCategoryId
),
"10"
)
:
null
;
const
memberConnectionQuery
=
memberConnectionCategoryId
?
createCategoryPostsQuery
(
String
(
memberConnectionCategoryId
),
"10"
)
:
null
;
const
trainingQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
daoTao
,
"10"
);
const
businessQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
coHoiKinhDoanh
,
"10"
);
const
memberConnectionQuery
=
createCategoryPostsQuery
(
HOME_CATEGORY_IDS
.
ketNoiHoiVien
,
"10"
);
const
[
featuredRows
,
...
...
@@ -337,9 +294,9 @@ async function fetchHomePosts() {
fetchHomePostRows
(
`/post?
${
policyQuery
.
toString
()}
`
),
fetchHomePostRows
(
`/post?
${
eventQuery
.
toString
()}
`
),
fetchHomePostRows
(
`/post?
${
quickLinksQuery
.
toString
()}
`
),
trainingQuery
?
fetchHomePostRows
(
`/post?
${
trainingQuery
.
toString
()}
`
)
:
[]
,
businessQuery
?
fetchHomePostRows
(
`/post?
${
businessQuery
.
toString
()}
`
)
:
[]
,
memberConnectionQuery
?
fetchHomePostRows
(
`/post?
${
memberConnectionQuery
.
toString
()}
`
)
:
[]
,
fetchHomePostRows
(
`/post?
${
trainingQuery
.
toString
()}
`
)
,
fetchHomePostRows
(
`/post?
${
businessQuery
.
toString
()}
`
)
,
fetchHomePostRows
(
`/post?
${
memberConnectionQuery
.
toString
()}
`
)
,
]);
const
rows
=
[
...
...
src/app/(main)/[...slug]/templates/ArticleDetailPage.tsx
View file @
2902be94
'use client'
;
'use client'
;
import
dayjs
from
"dayjs"
;
import
parse
from
"html-react-parser"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
EventsCalendar
from
"@/app/(main)/(home)/components/events-calendar"
;
import
{
getDynamicPostBodyHtml
}
from
"./data"
;
import
{
buildDynamicCategoryMenu
}
from
"./data"
;
import
StructuredPostContent
from
"./StructuredPostContent"
;
import
type
{
DynamicCategoryRouteItem
,
DynamicPostItem
}
from
"./types"
;
type
ArticleDetailPageProps
=
{
...
...
@@ -16,15 +17,18 @@ type ArticleDetailPageProps = {
export
default
function
ArticleDetailPage
({
post
,
category
,
allCategories
,
}:
ArticleDetailPageProps
)
{
const
publishedDate
=
dayjs
(
post
.
release_at
??
post
.
published_at
??
post
.
created_at
,
).
format
(
"DD/MM/YYYY"
);
const
primaryCategory
=
post
.
categories
[
0
]?.
name
||
category
?.
name
||
"Tin tức"
;
const
primaryCategory
=
post
.
categories
[
0
]?.
name
||
category
?.
name
||
"Tin tức"
;
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
return
(
<
div
className=
"min-h-screen bg-[#fbfbfa]"
>
<
div
className=
"container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"
>
<
div
className=
"min-h-screen bg-white"
>
{
/* {categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null} */
}
<
div
className=
"container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10"
>
<
div
className=
"grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12"
>
<
main
className=
"min-w-0"
>
<
div
className=
"mb-5 flex flex-wrap items-center gap-3 text-xs"
>
...
...
@@ -45,9 +49,9 @@ export default function ArticleDetailPage({
</
p
>
)
:
null
}
<
div
className=
"mt-7 rounded-
[24px]
bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10"
>
<
div
className=
"mt-7 rounded-
3xl
bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10"
>
<
div
className=
"article-detail-content prose tiptap max-w-none overflow-hidden"
>
{
parse
(
getDynamicPostBodyHtml
(
post
))
}
<
StructuredPostContent
post=
{
post
}
/>
</
div
>
</
div
>
...
...
@@ -116,7 +120,7 @@ export default function ArticleDetailPage({
</
div
>
</
main
>
<
aside
className=
"space-y-5"
>
<
aside
className=
"space-y-5
xl:pt-0
"
>
<
EventsCalendar
compact
className=
"xl:w-full xl:min-w-0"
/>
<
div
className=
"overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
>
<
div
className=
"relative min-h-[390px] bg-[#1f334f]"
>
...
...
@@ -127,13 +131,13 @@ export default function ArticleDetailPage({
height=
{
760
}
className=
"absolute inset-0 h-full w-full object-cover"
/>
<
div
className=
"absolute inset-0 bg-
gradient
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute inset-0 bg-
liner
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute bottom-8 left-7 right-7 text-white"
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.24em] text-white/70"
>
Đối tác quảng bá
</
div
>
<
div
className=
"mt-3 text-2xl font-bold leading-tight"
>
Business Combo cho
doanh nghiệp hội viên
Business Combo cho
hội viên doanh nghiệp
</
div
>
</
div
>
</
div
>
...
...
@@ -144,3 +148,4 @@ export default function ArticleDetailPage({
</
div
>
);
}
src/app/(main)/[...slug]/templates/ArticlePage.tsx
View file @
2902be94
...
...
@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination";
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
,
buildPostFilters
,
fetchDynamicPostList
,
resolveDynamicPostImage
,
...
...
@@ -102,15 +104,20 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
return
new
Map
(
entries
);
},
[
allCategories
]);
const
categoryMenu
=
useMemo
(
()
=>
buildDynamicCategoryMenu
(
category
,
allCategories
),
[
category
,
allCategories
],
);
return
(
<
div
className=
"min-h-screen bg-[#fbfbfa]"
>
<
div
className=
"min-h-screen bg-white"
>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
{
postsQuery
.
isLoading
?
(
<
div
className=
"flex justify-center items-center w-full h-64"
>
<
Spinner
/>
</
div
>
)
:
(
<
div
className=
"container mx-auto px-4 py-
8 sm:px-6 lg:px-10 lg:py
-10"
>
<
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"
>
{
category
.
name
}
...
...
@@ -197,9 +204,9 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
</
div
>
</
main
>
<
aside
className=
"
order-1 space-y-5 xl:order-2 xl:w-[320px]
xl:pt-0"
>
<
aside
className=
"
contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5
xl:pt-0"
>
<
form
className=
"
rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]
"
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
);
...
...
@@ -235,7 +242,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
</
div
>
</
form
>
<
div
className=
"o
verflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]
"
>
<
div
className=
"o
rder-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none
"
>
<
div
className=
"relative min-h-[390px] bg-[#1f334f]"
>
<
ImageNext
src=
"/banner.webp"
...
...
@@ -244,13 +251,13 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
height=
{
760
}
className=
"absolute inset-0 h-full w-full object-cover"
/>
<
div
className=
"absolute inset-0 bg-
gradient
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute inset-0 bg-
liner
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute bottom-8 left-7 right-7 text-white"
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.24em] text-white/70"
>
Đối tác quảng bá
</
div
>
<
div
className=
"mt-3 text-2xl font-bold leading-tight"
>
Business Combo cho
doanh nghiệp hội viên
Business Combo cho
hội viên doanh nghiệp
</
div
>
</
div
>
</
div
>
...
...
src/app/(main)/[...slug]/templates/CatalogPage.tsx
View file @
2902be94
...
...
@@ -9,7 +9,9 @@ import { Pagination } from "@/components/base/pagination";
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
,
buildPostFilters
,
fetchDynamicPostList
,
resolveDynamicPostImage
,
...
...
@@ -81,15 +83,17 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
const
totalPages
=
postsQuery
.
data
?.
totalPages
??
1
;
const
currentPage
=
Math
.
min
(
page
,
totalPages
);
const
paginatedPosts
=
postsQuery
.
data
?.
rows
??
[];
const
categoryMenu
=
buildDynamicCategoryMenu
(
category
,
allCategories
);
return
(
<
div
className=
"min-h-screen bg-[#fbfbfa]"
>
<
div
className=
"min-h-screen bg-white"
>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
{
postsQuery
.
isLoading
?
(
<
div
className=
"flex h-64 w-full items-center justify-center"
>
<
Spinner
/>
</
div
>
)
:
(
<
div
className=
"container mx-auto px-4 py-
8 sm:px-6 lg:px-10 lg:py
-10"
>
<
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"
>
{
category
.
name
}
...
...
@@ -109,7 +113,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
className=
"group block"
>
<
div
className=
"overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]"
>
<
div
className=
"relative aspect-
[3/4]
overflow-hidden bg-white"
>
<
div
className=
"relative aspect-
3/4
overflow-hidden bg-white"
>
<
ImageNext
src=
{
resolveDynamicPostImage
(
item
.
thumbnail
)
}
alt=
{
item
.
title
}
...
...
@@ -131,7 +135,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</
div
>
)
:
(
<
div
className=
"rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"
>
{
"Ch
\
u01b0a c
\
u00f3 b
\
u00e0i vi
\
u1ebft ph
\
u00f9 h
\
u1ee3p trong danh m
\
u1ee5c n
\
u00e0y."
}
Chưa có tài liệu trong danh mục.
</
div
>
)
}
...
...
@@ -146,9 +150,9 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</
div
>
</
main
>
<
aside
className=
"
order-1 space-y-5 xl:order-2 xl:w-[320px]
xl:pt-0"
>
<
aside
className=
"
contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5
xl:pt-0"
>
<
form
className=
"
rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]
"
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
);
...
...
@@ -184,7 +188,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
</
div
>
</
form
>
<
div
className=
"o
verflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]
"
>
<
div
className=
"o
rder-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0
"
>
<
div
className=
"relative min-h-[390px] bg-[#1f334f]"
>
<
ImageNext
src=
"/banner.webp"
...
...
@@ -193,13 +197,13 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
height=
{
760
}
className=
"absolute inset-0 h-full w-full object-cover"
/>
<
div
className=
"absolute inset-0 bg-
gradient
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute inset-0 bg-
liner
-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent"
/>
<
div
className=
"absolute bottom-8 left-7 right-7 text-white"
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.24em] text-white/70"
>
Đối tác quảng bá
</
div
>
<
div
className=
"mt-3 text-2xl font-bold leading-tight"
>
Business Combo cho
doanh nghiệp hội viên
Business Combo cho
hội viên doanh nghiệp
</
div
>
</
div
>
</
div
>
...
...
src/app/(main)/[...slug]/templates/InformationPage.tsx
View file @
2902be94
'use client'
;
import
dayjs
from
"dayjs"
;
import
parse
from
"html-react-parser"
;
import
{
getDynamicPostBodyHtml
}
from
"./data"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
}
from
"./data"
;
import
StructuredPostContent
from
"./StructuredPostContent"
;
import
type
{
DynamicCategoryRouteItem
,
DynamicPostItem
}
from
"./types"
;
type
InformationPageProps
=
{
...
...
@@ -14,21 +15,24 @@ type InformationPageProps = {
export
default
function
InformationPage
({
post
,
category
,
allCategories
,
}:
InformationPageProps
)
{
const
publishedDate
=
dayjs
(
post
.
release_at
??
post
.
published_at
??
post
.
created_at
,
).
format
(
"DD/MM/YYYY"
);
const
categoryMenu
=
buildDynamicCategoryMenu
(
category
,
allCategories
);
return
(
<
div
className=
"min-h-screen bg-[#fbfbfa]"
>
<
div
className=
"container mx-auto px-4 py-8 sm:px-6 lg:px-10 lg:py-10"
>
<
div
className=
"min-h-screen bg-white"
>
{
categoryMenu
.
length
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
null
}
<
div
className=
"container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10"
>
<
main
className=
"w-full"
>
<
div
className=
"mb-5 flex flex-wrap items-center gap-3 text-xs"
>
{
/*
<div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{category.name}
</span>
<span className="text-[#9aa3ad]">{publishedDate}</span>
</
div
>
</div>
*/
}
<
h1
className=
"max-w-6xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]"
>
{
post
.
title
}
...
...
@@ -41,9 +45,9 @@ export default function InformationPage({
</
p
>
)
:
null
}
<
div
className=
"mt-7 rounded-
[24px]
bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10"
>
<
div
className=
"mt-7 rounded-
3xl
bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10"
>
<
div
className=
"page-detail-content prose tiptap max-w-none overflow-hidden"
>
{
parse
(
getDynamicPostBodyHtml
(
post
))
}
<
StructuredPostContent
post=
{
post
}
/>
</
div
>
</
div
>
...
...
src/app/(main)/[...slug]/templates/StructuredPostContent.tsx
0 → 100644
View file @
2902be94
"use client"
;
import
parse
from
"html-react-parser"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
getDynamicPostBodyHtml
}
from
"./data"
;
import
type
{
DynamicPostContentSection
,
DynamicPostItem
}
from
"./types"
;
type
StructuredPostContentProps
=
{
post
:
DynamicPostItem
;
};
function
getGridClassName
(
columns
:
number
)
{
if
(
columns
>=
4
)
return
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
;
if
(
columns
===
3
)
return
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
;
if
(
columns
===
2
)
return
"grid-cols-1 sm:grid-cols-2"
;
return
"grid-cols-1"
;
}
function
StructuredImageSection
({
section
}:
{
section
:
DynamicPostContentSection
})
{
const
images
=
section
.
images
.
filter
((
item
)
=>
item
.
image
?.
url
);
if
(
!
images
.
length
)
return
null
;
return
(
<
div
className=
{
`not-prose my-6 grid gap-4 ${getGridClassName(section.image_columns)}`
}
>
{
images
.
map
((
item
)
=>
{
const
image
=
item
.
image
;
if
(
!
image
?.
url
)
return
null
;
return
(
<
figure
key=
{
`${section.id}-${image.id || image.url}-${item.position}`
}
className=
"overflow-hidden rounded-[18px] bg-white"
>
<
ImageNext
src=
{
image
.
url
}
alt=
{
image
.
alt
||
image
.
name
||
"Hình ảnh bài viết"
}
width=
{
1200
}
height=
{
800
}
className=
"h-auto w-full object-contain"
/>
</
figure
>
);
})
}
</
div
>
);
}
export
default
function
StructuredPostContent
({
post
}:
StructuredPostContentProps
)
{
const
sections
=
(
post
.
content_structure
?.
post_content
??
[])
.
slice
()
.
sort
((
left
,
right
)
=>
left
.
position
-
right
.
position
);
if
(
!
sections
.
length
)
{
return
<>
{
parse
(
getDynamicPostBodyHtml
(
post
))
}
</>;
}
const
hasRenderableSection
=
sections
.
some
(
(
section
)
=>
section
.
content
.
trim
()
||
section
.
images
.
length
,
);
if
(
!
hasRenderableSection
)
{
return
<>
{
parse
(
getDynamicPostBodyHtml
(
post
))
}
</>;
}
return
(
<>
{
sections
.
map
((
section
)
=>
{
if
(
section
.
type
===
"image"
)
{
return
<
StructuredImageSection
key=
{
section
.
id
}
section=
{
section
}
/>;
}
const
content
=
section
.
content
.
trim
();
if
(
!
content
)
return
null
;
return
<
div
key=
{
section
.
id
}
>
{
parse
(
content
)
}
</
div
>;
})
}
</>
);
}
src/app/(main)/[...slug]/templates/data.ts
View file @
2902be94
...
...
@@ -30,6 +30,18 @@ type RawPostThumbnail = {
url
?:
string
|
null
;
};
type
RawPostSectionImage
=
{
position
?:
number
|
null
;
image
?:
{
id
?:
string
|
null
;
name
?:
string
|
null
;
alt
?:
string
|
null
;
url
?:
string
|
null
;
path
?:
string
|
null
;
original
?:
string
|
null
;
}
|
null
;
};
type
RawPostItem
=
{
id
?:
string
|
null
;
title
?:
string
|
null
;
...
...
@@ -57,6 +69,9 @@ type RawPostItem = {
type
?:
string
|
null
;
content
?:
string
|
null
;
position
?:
number
|
null
;
image_rows
?:
number
|
null
;
image_columns
?:
number
|
null
;
images
?:
RawPostSectionImage
[]
|
null
;
}
>
|
null
;
}
|
null
;
};
...
...
@@ -111,6 +126,33 @@ const mapPostContentSections = (item: RawPostItem): DynamicPostContentSection[]
typeof
section
?.
position
===
"number"
?
section
.
position
:
index
+
1
,
image_rows
:
typeof
section
?.
image_rows
===
"number"
&&
section
.
image_rows
>
0
?
section
.
image_rows
:
1
,
image_columns
:
typeof
section
?.
image_columns
===
"number"
&&
section
.
image_columns
>
0
?
section
.
image_columns
:
1
,
images
:
(
section
?.
images
??
[])
.
map
((
item
,
imageIndex
)
=>
({
position
:
typeof
item
?.
position
===
"number"
?
item
.
position
:
imageIndex
+
1
,
image
:
item
?.
image
?
{
id
:
String
(
item
.
image
.
id
??
""
),
name
:
String
(
item
.
image
.
name
??
item
.
image
.
original
??
""
),
alt
:
String
(
item
.
image
.
alt
??
item
.
image
.
name
??
""
),
url
:
resolveUploadUrl
(
item
.
image
.
url
??
item
.
image
.
path
??
item
.
image
.
original
??
""
),
path
:
item
.
image
.
path
??
null
,
original
:
item
.
image
.
original
??
null
,
}
:
null
,
}))
.
filter
((
item
)
=>
Boolean
(
item
.
image
?.
url
))
.
sort
((
left
,
right
)
=>
left
.
position
-
right
.
position
),
}));
};
...
...
src/app/(main)/[...slug]/templates/types.ts
View file @
2902be94
...
...
@@ -34,6 +34,19 @@ export type DynamicPostContentSection = {
type
:
string
;
content
:
string
;
position
:
number
;
image_rows
:
number
;
image_columns
:
number
;
images
:
Array
<
{
position
:
number
;
image
:
{
id
:
string
;
name
:
string
;
alt
:
string
;
url
:
string
;
path
?:
string
|
null
;
original
?:
string
|
null
;
}
|
null
;
}
>
;
};
export
type
DynamicPostItem
=
{
...
...
src/app/(main)/_lib/layout/header.tsx
View file @
2902be94
...
...
@@ -126,6 +126,14 @@ function Header() {
};
},
[]);
useEffect
(()
=>
{
document
.
body
.
style
.
overflow
=
toggleMenu
?
"hidden"
:
""
;
return
()
=>
{
document
.
body
.
style
.
overflow
=
""
;
};
},
[
toggleMenu
]);
return
(
<
header
className=
"sticky top-0 z-50 shadow-[0_1px_0_rgba(15,23,42,0.05)]"
>
<
div
...
...
@@ -252,13 +260,32 @@ function Header() {
</
div
>
<
div
className=
{
`fixed
left-0 right-0 top-[80px] z-40 border-t border-slate-200
bg-white transition-all duration-300 lg:hidden ${
className=
{
`fixed
inset-0 z-[60]
bg-white transition-all duration-300 lg:hidden ${
toggleMenu
? "pointer-events-auto
h-[calc(100dvh-80px)]
translate-y-0 opacity-100"
: "pointer-events-none
h-[calc(100dvh-80px)]
-translate-y-2 opacity-0"
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none -translate-y-2 opacity-0"
}`
}
>
<
div
className=
"flex h-full flex-col overflow-y-auto overscroll-contain px-4 py-3"
>
<
div
className=
"flex h-full flex-col overflow-hidden"
>
<
div
className=
"sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Image
className=
"h-auto w-[108px] object-contain"
src=
{
logo
}
alt=
"VCCI-HCM"
priority
/>
</
Link
>
<
button
onClick=
{
()
=>
setToggleMenu
(
false
)
}
className=
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 bg-white text-[#163b73] transition hover:bg-slate-50"
aria
-
label=
{
"Đ
\
u00f3ng menu"
}
>
<
X
size=
{
18
}
/>
</
button
>
</
div
>
<
div
className=
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3"
>
<
input
className=
"h-11 w-full shrink-0 rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]"
type=
"text"
...
...
@@ -302,6 +329,7 @@ function Header() {
</
div
>
</
div
>
</
div
>
</
div
>
</
header
>
);
}
...
...
src/app/(main)/search/page.tsx
View file @
2902be94
...
...
@@ -140,7 +140,7 @@ function SearchContent() {
const
currentPage
=
Number
(
postsQuery
.
data
?.
page
??
page
);
return
(
<
div
className=
"min-h-screen bg-
[#fbfbfa]
"
>
<
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"
>
...
...
@@ -197,9 +197,9 @@ function SearchContent() {
)
}
</
main
>
<
aside
className=
"
order-1 space-y-5 xl:order-2 xl:w-[320px]
xl:pt-0"
>
<
aside
className=
"
contents xl:order-2 xl:block xl:w-[320px] xl:space-y-5
xl:pt-0"
>
<
form
className=
"
rounded-[22px] border border-[#edf1f5] bg-white p-5 shadow-[0_14px_34px_rgba(17,24,39,0.05)]
"
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
);
...
...
@@ -235,7 +235,7 @@ function SearchContent() {
</
div
>
</
form
>
<
div
className=
"o
verflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]
"
>
<
div
className=
"o
rder-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none
"
>
<
div
className=
"relative min-h-[390px] bg-[#1f334f]"
>
<
ImageNext
src=
"/banner.webp"
...
...
@@ -266,7 +266,7 @@ export default function Page() {
return
(
<
Suspense
fallback=
{
<
div
className=
"flex min-h-screen items-center justify-center bg-
[#fbfbfa]
"
>
<
div
className=
"flex min-h-screen items-center justify-center bg-
white
"
>
<
Spinner
className=
"size-8"
/>
</
div
>
}
...
...
src/app/(main)/video/page.tsx
View file @
2902be94
...
...
@@ -42,7 +42,7 @@ function VideoPageContent() {
};
return
(
<
div
className=
"min-h-screen bg-
[#fbfbfa]
"
>
<
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"
>
...
...
@@ -126,7 +126,7 @@ export default function Page() {
return
(
<
Suspense
fallback=
{
<
div
className=
"flex min-h-screen items-center justify-center bg-
[#fbfbfa]
"
>
<
div
className=
"flex min-h-screen items-center justify-center bg-
white
"
>
<
Spinner
className=
"size-8"
/>
</
div
>
}
...
...
src/components/base/list-category/index.tsx
View file @
2902be94
...
...
@@ -19,10 +19,10 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
const
isActive
=
(
href
:
string
)
=>
pathname
===
href
;
return
(
<
div
className=
"border-t border-gray-200 bg-white
py-2
"
>
<
div
className=
"
w-full px-4 sm:px-6 lg:px-8
"
>
<
div
className=
"p
y-3
"
>
<
div
className=
"
flex max-w-full items-center gap-3 overflow-x-auto pb-1
"
>
<
div
className=
"border-t border-gray-200 bg-white"
>
<
div
className=
"
container mx-auto px-4 sm:px-6 lg:px-10
"
>
<
div
className=
"p
t-6
"
>
<
div
className=
"
client-category-scrollbar flex max-w-full items-center gap-3 overflow-x-auto overflow-y-hidden pb-1 pl-0.5 pr-2
"
>
{
categories
.
map
((
category
)
=>
{
const
href
=
resolveHref
(
category
);
const
menu
=
{
id
:
category
.
id
,
name
:
category
.
name
,
link
:
href
};
...
...
@@ -37,6 +37,29 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
</
div
>
</
div
>
</
div
>
<
style
jsx
global
>
{
`
.client-category-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(22, 85, 157, 0.35) transparent;
}
.client-category-scrollbar::-webkit-scrollbar {
height: 6px;
}
.client-category-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.client-category-scrollbar::-webkit-scrollbar-thumb {
background: rgba(22, 85, 157, 0.28);
border-radius: 999px;
}
.client-category-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(22, 85, 157, 0.48);
}
`
}
</
style
>
</
div
>
);
};
...
...
src/components/base/menu-category/index.tsx
View file @
2902be94
...
...
@@ -40,7 +40,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
href={normalizedLink}
className={menuItemTriggerClass(variant)}
>
<span className=
"relative z-10 truncate"
>{menu.name}</span>
<span className=
{cn("relative z-10", variant === "main" ? "truncate" : "")}
>{menu.name}</span>
{variant === 'main' ? <span className="menu-item-underline" aria-hidden="true" /> : null}
</Link>
)
...
...
@@ -66,7 +66,7 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
function menuItemTriggerClass(variant: 'main' | 'secondary') {
if (variant === 'secondary') {
return cn(
'inline-flex
h-[36px] items-center justify-center rounded-full border border-[#d6dfeb] bg-white px-5 text-[13px] font-medium leading-none text-[#5f6b7d] shadow-none transition-colors duration-150
',
'inline-flex
min-h-[38px] max-w-[280px] items-center justify-center rounded-full border border-[#d6dfeb] bg-white px-5 py-2 text-center text-[13px] font-medium leading-[1.25] text-[#5f6b7d] shadow-none transition-colors duration-150 sm:max-w-none sm:whitespace-nowrap
',
'hover:border-[#c5d2e3] hover:bg-[#f7faff] hover:text-[#1b5aa1]',
'aria-selected:border-[#16559d] aria-selected:bg-[#16559d] aria-selected:text-white',
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
...
...
src/links/index.ts
View file @
2902be94
...
...
@@ -3,7 +3,11 @@ const DEFAULT_BACKEND_ORIGIN = "https://vietprodev.duckdns.org/gateway/vcci-news
const
normalizeOrigin
=
(
value
?:
string
|
null
)
=>
value
?.
trim
().
replace
(
/
\/
+$/
,
""
)
||
""
;
const
readOrigin
=
(
key
:
"NEXT_PUBLIC_BACKEND_HOST"
|
"NEXT_PUBLIC_FRONTEND_HOST"
)
=>
{
const
envOrigin
=
normalizeOrigin
(
process
.
env
[
key
]);
const
envOrigin
=
normalizeOrigin
(
key
===
"NEXT_PUBLIC_BACKEND_HOST"
?
process
.
env
.
NEXT_PUBLIC_BACKEND_HOST
:
process
.
env
.
NEXT_PUBLIC_FRONTEND_HOST
,
);
if
(
envOrigin
)
return
envOrigin
;
if
(
key
===
"NEXT_PUBLIC_BACKEND_HOST"
&&
process
.
env
.
NODE_ENV
===
"production"
)
{
return
DEFAULT_BACKEND_ORIGIN
;
...
...
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