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
c6e718f7
Commit
c6e718f7
authored
Aug 28, 2026
by
Văn Hoàng
Browse files
Options
Browse Files
Download
Plain Diff
[tag]0.1-vcci
parents
d7347ab7
2ffb9901
Pipeline
#52913
passed with stages
in 8 minutes and 2 seconds
Changes
3
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
262 additions
and
136 deletions
+262
-136
DynamicPageClient.tsx
src/app/(main)/[...slug]/DynamicPageClient.tsx
+146
-0
page.tsx
src/app/(main)/[...slug]/page.tsx
+80
-136
data.ts
src/app/(main)/[...slug]/templates/data.ts
+36
-0
No files found.
src/app/(main)/[...slug]/DynamicPageClient.tsx
0 → 100644
View file @
c6e718f7
"use client"
;
import
{
useEffect
,
useMemo
}
from
"react"
;
import
{
notFound
,
useParams
,
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
ArticlePage
from
"./templates/ArticlePage"
;
import
ArticleDetailPage
from
"./templates/ArticleDetailPage"
;
import
CatalogPage
from
"./templates/CatalogPage"
;
import
InformationPage
from
"./templates/InformationPage"
;
import
{
fetchDynamicCategories
,
fetchDynamicPostById
,
fetchDynamicPostByExternalLink
,
fetchDynamicSinglePagePost
,
findDynamicCategoryByPath
,
findFirstChildCategory
,
findMenuCategoryForPost
,
}
from
"./templates/data"
;
export
default
function
DynamicPageClient
()
{
const
params
=
useParams
();
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
const
path
=
slug
.
join
(
"/"
);
const
routePath
=
`/
${
path
}
`
;
const
router
=
useRouter
();
const
searchParams
=
useSearchParams
();
const
postId
=
searchParams
.
get
(
"id"
)?.
trim
()
??
""
;
const
preferredCategoryId
=
searchParams
.
get
(
"categoryId"
)?.
trim
()
??
""
;
const
categoryQuery
=
useQuery
({
queryKey
:
[
"dynamic-categories"
],
queryFn
:
fetchDynamicCategories
,
staleTime
:
5
*
60
*
1000
,
});
const
matchedCategory
=
useMemo
(
()
=>
findDynamicCategoryByPath
(
categoryQuery
.
data
??
[],
routePath
),
[
categoryQuery
.
data
,
routePath
],
);
const
detailQuery
=
useQuery
({
queryKey
:
[
"dynamic-post-detail"
,
postId
||
routePath
],
queryFn
:
()
=>
postId
?
fetchDynamicPostById
(
postId
)
:
fetchDynamicPostByExternalLink
(
routePath
),
enabled
:
(
Boolean
(
postId
)
||
Boolean
(
routePath
))
&&
!
categoryQuery
.
isLoading
&&
(
Boolean
(
postId
)
||
!
matchedCategory
),
staleTime
:
60
*
1000
,
});
const
resolvedCategory
=
useMemo
(
()
=>
(
preferredCategoryId
?
categoryQuery
.
data
?.
find
((
item
)
=>
item
.
id
===
preferredCategoryId
)
??
null
:
null
)
??
matchedCategory
??
findMenuCategoryForPost
(
detailQuery
.
data
??
null
,
categoryQuery
.
data
??
[]),
[
preferredCategoryId
,
matchedCategory
,
detailQuery
.
data
,
categoryQuery
.
data
],
);
const
singlePageQuery
=
useQuery
({
queryKey
:
[
"dynamic-single-page-post"
,
resolvedCategory
?.
id
],
queryFn
:
()
=>
fetchDynamicSinglePagePost
(
resolvedCategory
!
.
id
),
enabled
:
resolvedCategory
?.
type
===
"page"
,
staleTime
:
60
*
1000
,
});
useEffect
(()
=>
{
if
(
!
matchedCategory
||
matchedCategory
.
type
!==
"category"
)
return
;
const
firstChild
=
findFirstChildCategory
(
matchedCategory
,
categoryQuery
.
data
??
[]);
if
(
slug
.
length
===
1
&&
firstChild
?.
url
)
{
router
.
replace
(
firstChild
.
url
);
}
},
[
matchedCategory
,
categoryQuery
.
data
,
router
,
slug
.
length
]);
const
isLoading
=
categoryQuery
.
isLoading
||
detailQuery
.
isLoading
||
(
resolvedCategory
?.
type
===
"page"
&&
singlePageQuery
.
isLoading
);
if
(
isLoading
)
{
return
(
<
div
className=
"flex min-h-[50vh] items-center justify-center"
>
<
Spinner
/>
</
div
>
);
}
if
(
detailQuery
.
data
)
{
return
(
<
ArticleDetailPage
post=
{
detailQuery
.
data
}
category=
{
resolvedCategory
}
allCategories=
{
categoryQuery
.
data
??
[]
}
/>
);
}
if
(
resolvedCategory
?.
type
===
"page"
)
{
if
(
!
singlePageQuery
.
data
)
return
notFound
();
return
(
<
InformationPage
post=
{
singlePageQuery
.
data
}
category=
{
resolvedCategory
}
allCategories=
{
categoryQuery
.
data
??
[]
}
/>
);
}
if
(
resolvedCategory
?.
type
===
"news"
)
{
if
(
resolvedCategory
.
slug
===
"an-pham"
||
resolvedCategory
.
slug
===
"thu-vien-tai-lieu"
)
{
return
(
<
CatalogPage
category=
{
resolvedCategory
}
allCategories=
{
categoryQuery
.
data
??
[]
}
/>
);
}
return
(
<
ArticlePage
category=
{
resolvedCategory
}
allCategories=
{
categoryQuery
.
data
??
[]
}
/>
);
}
if
(
resolvedCategory
?.
type
===
"category"
)
{
return
(
<
div
className=
"flex min-h-[50vh] items-center justify-center"
>
<
Spinner
/>
</
div
>
);
}
return
notFound
();
}
src/app/(main)/[...slug]/page.tsx
View file @
c6e718f7
"use client"
;
import
type
{
Metadata
}
from
"next"
;
import
links
from
"@links/index"
;
import
{
useEffect
,
useMemo
}
from
"react"
;
import
{
notFound
,
useParams
,
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
ArticlePage
from
"./templates/ArticlePage"
;
import
ArticleDetailPage
from
"./templates/ArticleDetailPage"
;
import
CatalogPage
from
"./templates/CatalogPage"
;
import
InformationPage
from
"./templates/InformationPage"
;
import
{
import
{
fetchDynamicCategories
,
fetchDynamicPostById
,
fetchDynamicPostById
,
fetchDynamicPostByExternalLink
,
fetchDynamicPostByExternalLink
,
fetchDynamicSinglePagePost
,
getDynamicPostSeoImage
,
findDynamicCategoryByPath
,
getDynamicPostExcerpt
,
findFirstChildCategory
,
stripHtml
,
findMenuCategoryForPost
,
}
from
"./templates/data"
;
}
from
"./templates/data"
;
import
DynamicPageClient
from
"./DynamicPageClient"
;
export
default
function
DynamicPage
()
{
const
params
=
useParams
();
type
GenerateMetadataArgs
=
{
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
params
:
Promise
<
{
slug
:
string
[]
}
>
;
const
path
=
slug
.
join
(
"/"
);
searchParams
:
Promise
<
{
id
?:
string
;
categoryId
?:
string
}
>
;
const
routePath
=
`/
${
path
}
`
;
};
const
router
=
useRouter
();
const
searchParams
=
useSearchParams
();
export
async
function
generateMetadata
({
const
postId
=
searchParams
.
get
(
"id"
)?.
trim
()
??
""
;
params
,
const
preferredCategoryId
=
searchParams
.
get
(
"categoryId"
)?.
trim
()
??
""
;
searchParams
,
}:
GenerateMetadataArgs
):
Promise
<
Metadata
>
{
const
categoryQuery
=
useQuery
({
const
{
slug
}
=
await
params
;
queryKey
:
[
"dynamic-categories"
],
const
{
id
,
categoryId
}
=
await
searchParams
;
queryFn
:
fetchDynamicCategories
,
staleTime
:
5
*
60
*
1000
,
const
path
=
`/
${(
slug
??
[]).
join
(
"/"
)}
`;
});
const postId = id?.trim() ?? "";
const categoryIdParam = categoryId?.trim() ?? "";
const
matchedCategory
=
useMemo
(
()
=>
findDynamicCategoryByPath
(
categoryQuery
.
data
??
[],
routePath
),
let post = null;
[
categoryQuery
.
data
,
routePath
],
try {
);
post = postId
? await fetchDynamicPostById(postId)
const
detailQuery
=
useQuery
({
: await fetchDynamicPostByExternalLink(path);
queryKey
:
[
"dynamic-post-detail"
,
postId
||
routePath
],
} catch {
queryFn
:
()
=>
post = null;
postId
?
fetchDynamicPostById
(
postId
)
:
fetchDynamicPostByExternalLink
(
routePath
),
enabled
:
(
Boolean
(
postId
)
||
Boolean
(
routePath
))
&&
!
categoryQuery
.
isLoading
&&
(
Boolean
(
postId
)
||
!
matchedCategory
),
staleTime
:
60
*
1000
,
});
const
resolvedCategory
=
useMemo
(
()
=>
(
preferredCategoryId
?
categoryQuery
.
data
?.
find
((
item
)
=>
item
.
id
===
preferredCategoryId
)
??
null
:
null
)
??
matchedCategory
??
findMenuCategoryForPost
(
detailQuery
.
data
??
null
,
categoryQuery
.
data
??
[]),
[
preferredCategoryId
,
matchedCategory
,
detailQuery
.
data
,
categoryQuery
.
data
],
);
const
singlePageQuery
=
useQuery
({
queryKey
:
[
"dynamic-single-page-post"
,
resolvedCategory
?.
id
],
queryFn
:
()
=>
fetchDynamicSinglePagePost
(
resolvedCategory
!
.
id
),
enabled
:
resolvedCategory
?.
type
===
"page"
,
staleTime
:
60
*
1000
,
});
useEffect
(()
=>
{
if
(
!
matchedCategory
||
matchedCategory
.
type
!==
"category"
)
return
;
const
firstChild
=
findFirstChildCategory
(
matchedCategory
,
categoryQuery
.
data
??
[]);
if
(
slug
.
length
===
1
&&
firstChild
?.
url
)
{
router
.
replace
(
firstChild
.
url
);
}
}
},
[
matchedCategory
,
categoryQuery
.
data
,
router
,
slug
.
length
]);
const
isLoading
=
if (!post || !post.title) {
categoryQuery
.
isLoading
||
return {
detailQuery
.
isLoading
||
title: "Bài viết không tìm thấy",
(
resolvedCategory
?.
type
===
"page"
&&
singlePageQuery
.
isLoading
);
robots: { index: false, follow: false },
};
if
(
isLoading
)
{
return
(
<
div
className=
"flex min-h-[50vh] items-center justify-center"
>
<
Spinner
/>
</
div
>
);
}
}
if
(
detailQuery
.
data
)
{
const title = post.title;
return
(
const description =
<
ArticleDetailPage
stripHtml(post.summary) ||
post=
{
detailQuery
.
data
}
getDynamicPostExcerpt(post).slice(0, 160) ||
category=
{
resolvedCategory
}
"Tin tức từ VCCI HCM";
allCategories=
{
categoryQuery
.
data
??
[]
}
const imageUrl = getDynamicPostSeoImage(post);
/>
const articleUrl = `
$
{
links
.
siteURL
.
replace
(
/
\/
+$/
,
""
)}
$
{
path
}
$
{
);
postId
||
categoryIdParam
}
?
`?
${
new
URLSearchParams
({
...(
postId
&&
{
id
:
postId
}),
if
(
resolvedCategory
?.
type
===
"page"
)
{
...(
categoryIdParam
&&
{
categoryId
:
categoryIdParam
}),
if
(
!
singlePageQuery
.
data
)
return
notFound
();
}).
toString
()}
`
:
""
return
(
}
`;
<
InformationPage
post=
{
singlePageQuery
.
data
}
return {
category=
{
resolvedCategory
}
title,
allCategories=
{
categoryQuery
.
data
??
[]
}
description,
/>
alternates: { canonical: articleUrl },
);
openGraph: {
}
title,
description,
if
(
resolvedCategory
?.
type
===
"news"
)
{
url: articleUrl,
if
(
siteName: "VCCI HCM",
resolvedCategory
.
slug
===
"an-pham"
||
images: [
resolvedCategory
.
slug
===
"thu-vien-tai-lieu"
{
)
{
url: imageUrl,
return
(
width: 1200,
<
CatalogPage
height: 630,
category=
{
resolvedCategory
}
alt: title,
allCategories=
{
categoryQuery
.
data
??
[]
}
},
/>
],
);
locale: "vi_VN",
}
type: "article",
},
return
(
twitter: {
<
ArticlePage
card: "summary_large_image",
category=
{
resolvedCategory
}
title,
allCategories=
{
categoryQuery
.
data
??
[]
}
description,
/>
images: [imageUrl],
);
},
}
};
}
if
(
resolvedCategory
?.
type
===
"category"
)
{
return
(
<
div
className=
"flex min-h-[50vh] items-center justify-center"
>
<
Spinner
/>
</
div
>
);
}
return
notFound
();
export default function Page() {
return <DynamicPageClient />;
}
}
src/app/(main)/[...slug]/templates/data.ts
View file @
c6e718f7
...
@@ -522,6 +522,42 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
...
@@ -522,6 +522,42 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
return
resolveUploadUrl
(
value
);
return
resolveUploadUrl
(
value
);
}
}
export
function
extractFirstImageFromHtml
(
html
?:
string
|
null
):
string
{
if
(
!
html
)
return
""
;
const
imgRegex
=
/<img
[^
>
]
*
\s
src=
[
"'
]([^
"'
]
+
)[
"'
]
/i
;
const
match
=
html
.
match
(
imgRegex
);
return
match
?.[
1
]?.
trim
()
??
""
;
}
export
function
getDynamicPostSeoImage
(
post
:
DynamicPostItem
|
null
):
string
{
if
(
!
post
)
return
"/thumbnail.png"
;
if
(
post
.
thumbnail
)
{
const
resolved
=
resolveDynamicPostImage
(
post
.
thumbnail
);
if
(
resolved
&&
resolved
!==
"/thumbnail.png"
)
return
resolved
;
}
const
sections
=
post
.
content_structure
?.
post_content
??
[];
for
(
const
section
of
sections
)
{
for
(
const
item
of
section
.
images
)
{
const
url
=
item
.
image
?.
url
;
if
(
url
)
return
url
;
}
}
for
(
const
section
of
sections
)
{
const
htmlImage
=
extractFirstImageFromHtml
(
section
.
content
);
if
(
htmlImage
)
return
resolveUploadUrl
(
htmlImage
);
}
const
htmlImage
=
extractFirstImageFromHtml
(
post
.
content
)
||
extractFirstImageFromHtml
(
post
.
summary
);
if
(
htmlImage
)
return
resolveUploadUrl
(
htmlImage
);
return
"/thumbnail.png"
;
}
export
function
stripHtml
(
value
?:
string
|
null
)
{
export
function
stripHtml
(
value
?:
string
|
null
)
{
if
(
!
value
)
return
""
;
if
(
!
value
)
return
""
;
...
...
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