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
82063bf1
Commit
82063bf1
authored
May 14, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
d04f19c2
Changes
49
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
49 changed files
with
3076 additions
and
1499 deletions
+3076
-1499
query-client.ts
src/api/config/query-client.ts
+17
-18
custom-client.ts
src/api/mutator/custom-client.ts
+52
-23
index.tsx
...(main)/(home)/components/business-opportunities/index.tsx
+55
-48
index.tsx
src/app/(main)/(home)/components/events-calendar/index.tsx
+15
-27
index.tsx
src/app/(main)/(home)/components/events/index.tsx
+79
-65
index.tsx
src/app/(main)/(home)/components/featured-news/index.tsx
+91
-97
index.tsx
src/app/(main)/(home)/components/members/index.tsx
+12
-21
index.tsx
src/app/(main)/(home)/components/news/index.tsx
+98
-95
index.tsx
src/app/(main)/(home)/components/policies-and-laws/index.tsx
+39
-45
use-home-posts.ts
src/app/(main)/(home)/lib/use-home-posts.ts
+339
-0
page.tsx
src/app/(main)/[...slug]/page.tsx
+86
-41
ArticleDetailPage.tsx
src/app/(main)/[...slug]/templates/ArticleDetailPage.tsx
+32
-31
ArticlePage.tsx
src/app/(main)/[...slug]/templates/ArticlePage.tsx
+118
-51
EventPage.tsx
src/app/(main)/[...slug]/templates/EventPage.tsx
+10
-4
InformationPage.tsx
src/app/(main)/[...slug]/templates/InformationPage.tsx
+32
-52
data.ts
src/app/(main)/[...slug]/templates/data.ts
+337
-0
types.ts
src/app/(main)/[...slug]/templates/types.ts
+63
-0
page.tsx
src/app/admin/base-config/page.tsx
+11
-7
page.tsx
src/app/admin/contact-management/contact-requests/page.tsx
+8
-22
page.tsx
...admin/contact-management/membership-applications/page.tsx
+12
-22
page.tsx
src/app/admin/contact-management/newsletter-emails/page.tsx
+10
-24
page.tsx
src/app/admin/dashboard/page.tsx
+2
-2
page.tsx
src/app/admin/header-config/[categoryId]/posts/page.tsx
+18
-42
header-category-table.tsx
.../admin/header-config/components/header-category-table.tsx
+48
-63
page.tsx
src/app/admin/header-config/page.tsx
+17
-0
page.tsx
src/app/admin/login/page.tsx
+12
-40
page.tsx
src/app/admin/media/page.tsx
+1
-1
page.tsx
src/app/admin/members/fields/page.tsx
+8
-21
page.tsx
src/app/admin/members/page.tsx
+16
-36
page.tsx
src/app/admin/members/regions/page.tsx
+8
-21
page.tsx
src/app/admin/news/page.tsx
+208
-106
page.tsx
src/app/admin/tags/page.tsx
+89
-26
page.tsx
src/app/admin/videos/page.tsx
+8
-21
admin-delete-dialog.tsx
src/components/admin/admin-delete-dialog.tsx
+13
-7
admin-row-actions.tsx
src/components/admin/admin-row-actions.tsx
+133
-0
admin-table-layout.tsx
src/components/admin/admin-table-layout.tsx
+1
-1
image-picker.tsx
src/components/admin/image-picker.tsx
+2
-2
news-form.tsx
src/components/admin/news-form.tsx
+29
-23
index.tsx
src/components/base/card-news/index.tsx
+35
-26
index.tsx
src/components/base/list-category/index.tsx
+11
-13
index.tsx
src/components/base/list-filter/index.tsx
+74
-62
admin-auth-guard.tsx
src/components/shared/admin-auth-guard.tsx
+49
-6
admin-header.tsx
src/components/shared/admin-header.tsx
+21
-15
AppEditorContent.tsx
src/components/shared/editor-content/AppEditorContent.tsx
+1
-1
cms-admin.ts
src/lib/api/cms-admin.ts
+35
-7
admin-auth.ts
src/lib/auth/admin-auth.ts
+316
-0
admin-news.ts
src/mockdata/admin-news.ts
+216
-170
categories.ts
src/mockdata/categories.ts
+1
-1
useAuthStore.ts
src/store/useAuthStore.ts
+188
-93
No files found.
src/api/config/query-client.ts
View file @
82063bf1
...
@@ -4,7 +4,7 @@ import { QueryClient } from '@tanstack/react-query'
...
@@ -4,7 +4,7 @@ import { QueryClient } from '@tanstack/react-query'
// App
// App
// import router from '@/router'
// import router from '@/router'
import
useAuthStore
from
'@/store/useAuthStore'
import
{
handleAdminUnauthorized
}
from
'@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
// import useProfileStore from '@stores/profile'
import
{
QueryData
}
from
'@/lib/types/base-api'
import
{
QueryData
}
from
'@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path'
// import { BASE_PATHS } from '@/constants/path'
...
@@ -41,14 +41,13 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
...
@@ -41,14 +41,13 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
// Handle un authorization error
// Handle un authorization error
const
handleUnAuthorizationError
=
()
=>
{
const
handleUnAuthorizationError
=
()
=>
{
useAuthStore
.
getState
().
resetStore
()
void
handleAdminUnauthorized
()
// useProfileStore.getState().resetStore()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// path: BASE_PATHS.authSignIn
// })
// })
// router.navigate('')
// router.navigate('')
window
.
location
.
href
=
process
?
'/'
:
'/admin'
}
}
// Handle delay value
// Handle delay value
...
...
src/api/mutator/custom-client.ts
View file @
82063bf1
import
Axios
,
{
AxiosError
,
AxiosRequestConfig
}
from
"axios"
;
import
Axios
,
{
AxiosError
,
AxiosHeaders
,
AxiosRequestConfig
,
InternalAxiosRequestConfig
}
from
"axios"
;
import
{
ensureValidAdminAccessToken
,
refreshAdminAccessToken
,
}
from
"@/lib/auth/admin-auth"
;
interface
RetriableAxiosRequestConfig
extends
InternalAxiosRequestConfig
{
_retry
?:
boolean
;
}
const
createAxiosInstance
=
()
=>
{
const
createAxiosInstance
=
()
=>
{
const
instance
=
Axios
.
create
({
const
instance
=
Axios
.
create
({
...
@@ -6,11 +14,17 @@ const createAxiosInstance = () => {
...
@@ -6,11 +14,17 @@ const createAxiosInstance = () => {
withCredentials
:
true
,
withCredentials
:
true
,
});
});
instance
.
interceptors
.
request
.
use
((
config
)
=>
{
instance
.
interceptors
.
request
.
use
(
async
(
config
)
=>
{
const
token
=
getPersistedAccessToken
();
if
(
shouldSkipAuthHandling
(
config
.
url
))
{
return
config
;
}
if
(
token
&&
!
config
.
headers
.
Authorization
)
{
const
token
=
await
ensureValidAdminAccessToken
().
catch
(()
=>
null
);
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
;
if
(
token
)
{
const
headers
=
AxiosHeaders
.
from
(
config
.
headers
);
headers
.
set
(
"Authorization"
,
`Bearer
${
token
}
`
);
config
.
headers
=
headers
;
}
}
return
config
;
return
config
;
...
@@ -18,7 +32,36 @@ const createAxiosInstance = () => {
...
@@ -18,7 +32,36 @@ const createAxiosInstance = () => {
instance
.
interceptors
.
response
.
use
(
instance
.
interceptors
.
response
.
use
(
async
(
response
)
=>
response
,
async
(
response
)
=>
response
,
(
error
)
=>
Promise
.
reject
(
error
),
async
(
error
:
AxiosError
)
=>
{
const
originalRequest
=
error
.
config
as
RetriableAxiosRequestConfig
|
undefined
;
if
(
error
.
response
?.
status
!==
401
||
!
originalRequest
||
originalRequest
.
_retry
||
shouldSkipAuthHandling
(
originalRequest
.
url
)
)
{
return
Promise
.
reject
(
error
);
}
originalRequest
.
_retry
=
true
;
try
{
const
nextAccessToken
=
await
refreshAdminAccessToken
();
if
(
!
nextAccessToken
)
{
return
Promise
.
reject
(
error
);
}
const
headers
=
AxiosHeaders
.
from
(
originalRequest
.
headers
);
headers
.
set
(
"Authorization"
,
`Bearer
${
nextAccessToken
}
`
);
originalRequest
.
headers
=
headers
;
return
instance
(
originalRequest
);
}
catch
(
refreshError
)
{
return
Promise
.
reject
(
refreshError
);
}
},
);
);
return
instance
;
return
instance
;
...
@@ -26,23 +69,9 @@ const createAxiosInstance = () => {
...
@@ -26,23 +69,9 @@ const createAxiosInstance = () => {
const
AXIOS_INSTANCE
=
createAxiosInstance
();
const
AXIOS_INSTANCE
=
createAxiosInstance
();
const
getPersistedAccessToken
=
()
=>
{
const
shouldSkipAuthHandling
=
(
url
?:
string
|
null
)
=>
{
if
(
typeof
window
===
"undefined"
)
return
null
;
if
(
!
url
)
return
false
;
return
/
\/
auth
\/(
login|refresh|logout
)(\?
|$
)
/
.
test
(
url
);
try
{
const
rawAuthStorage
=
window
.
localStorage
.
getItem
(
"app-auth-storage"
);
if
(
!
rawAuthStorage
)
return
null
;
const
parsedAuthStorage
=
JSON
.
parse
(
rawAuthStorage
)
as
{
state
?:
{
appAccessToken
?:
string
|
null
;
};
};
return
parsedAuthStorage
.
state
?.
appAccessToken
??
null
;
}
catch
{
return
null
;
}
};
};
const
convertHeaders
=
(
headers
?:
HeadersInit
):
Record
<
string
,
string
>
|
undefined
=>
{
const
convertHeaders
=
(
headers
?:
HeadersInit
):
Record
<
string
,
string
>
|
undefined
=>
{
...
...
src/app/(main)/(home)/components/business-opportunities/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
businessItems
=
getAdminNewsSeed
()
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
!
item
.
is_hidden
&&
(
item
.
category_ids
.
includes
(
"cat-business-opportunity"
)
||
item
.
tagsearch_values
.
some
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
"cơ hội kinh doanh"
))),
)
.
sort
(
(
left
,
right
)
=>
new
Date
(
right
.
published_at
||
right
.
created_at
).
getTime
()
-
new
Date
(
left
.
published_at
||
left
.
created_at
).
getTime
(),
);
function
formatPublishDate
(
item
:
AdminNewsItem
)
{
return
dayjs
(
item
.
published_at
||
item
.
created_at
).
format
(
"DD/MM/YYYY"
);
}
function
BusinessOpportunities
()
{
function
BusinessOpportunities
()
{
const
{
businessPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
businessItems
=
businessPosts
;
const
[
featuredItem
,
...
listItems
]
=
businessItems
;
const
[
featuredItem
,
...
listItems
]
=
businessItems
;
const
listSlots
=
Array
.
from
({
length
:
3
},
(
_
,
index
)
=>
listItems
[
index
]
??
null
);
if
(
!
featuredItem
)
return
null
;
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
coHoiKinhDoanh
.
toLowerCase
())
??
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh"
;
return
(
return
(
<
section
className=
"flex-1"
>
<
section
className=
"flex-1"
>
...
@@ -42,7 +25,7 @@ function BusinessOpportunities() {
...
@@ -42,7 +25,7 @@ function BusinessOpportunities() {
</
div
>
</
div
>
<
Link
<
Link
href=
"/xuc-tien-thuong-mai/co-hoi/"
href=
{
sectionLink
}
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
>
<
ChevronRight
className=
"h-5 w-5"
/>
<
ChevronRight
className=
"h-5 w-5"
/>
...
@@ -50,21 +33,31 @@ function BusinessOpportunities() {
...
@@ -50,21 +33,31 @@ function BusinessOpportunities() {
</
div
>
</
div
>
<
div
className=
"space-y-3"
>
<
div
className=
"space-y-3"
>
{
featuredItem
?
(
<
Link
<
Link
href=
"/xuc-tien-thuong-mai/co-hoi/"
href=
{
featuredItem
.
externalLink
}
className=
"block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
className=
"block rounded-[18px] bg-[#f5f7fb] px-4 py-3.5 transition-colors hover:bg-[#eef3fb]"
>
>
<
h3
className=
"line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]"
>
<
h3
className=
"line-clamp-2 text-[16px] font-bold leading-[1.45] text-[#264798] md:text-[17px]"
>
{
featuredItem
.
title
}
{
featuredItem
.
title
}
</
h3
>
</
h3
>
<
p
className=
"mt-2 text-[13px] text-[#9aa8c1]"
>
{
formatPublishDate
(
featuredItem
)
}
</
p
>
<
p
className=
"mt-2 text-[13px] text-[#9aa8c1]"
>
{
dayjs
(
featuredItem
.
publishedAt
||
featuredItem
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
Link
>
</
Link
>
)
:
(
<
div
className=
"rounded-[18px] bg-[#f5f7fb] px-4 py-3.5"
>
<
div
className=
"h-6 w-5/6 rounded bg-white"
/>
<
div
className=
"mt-2 h-4 w-24 rounded bg-white/80"
/>
</
div
>
)
}
<
div
className=
"space-y-2.5"
>
<
div
className=
"space-y-2.5"
>
{
listItems
.
slice
(
0
,
3
).
map
((
item
)
=>
(
{
listSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
<
Link
key=
{
item
.
id
}
key=
{
item
.
id
}
href=
"/xuc-tien-thuong-mai/co-hoi/"
href=
{
item
.
externalLink
}
className=
"flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
className=
"flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]"
/>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]"
/>
...
@@ -72,10 +65,24 @@ function BusinessOpportunities() {
...
@@ -72,10 +65,24 @@ function BusinessOpportunities() {
<
h4
className=
"line-clamp-2 text-[15px] leading-[1.45] text-[#264798]"
>
<
h4
className=
"line-clamp-2 text-[15px] leading-[1.45] text-[#264798]"
>
{
item
.
title
}
{
item
.
title
}
</
h4
>
</
h4
>
<
p
className=
"mt-1.5 text-[13px] text-[#9aa8c1]"
>
{
formatPublishDate
(
item
)
}
</
p
>
<
p
className=
"mt-1.5 text-[13px] text-[#9aa8c1]"
>
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
</
Link
>
))
}
)
:
(
<
div
key=
{
`business-placeholder-${index}`
}
className=
"flex gap-3 rounded-[14px] px-0.5 py-1"
>
<
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 rounded bg-[#eef3fb]"
/>
<
div
className=
"mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]"
/>
</
div
>
</
div
>
),
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
</
section
>
...
...
src/app/(main)/(home)/components/events-calendar/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
import
{
useHomePosts
,
type
HomePostItem
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
{
addMonths
,
format
,
getDay
,
startOfMonth
,
subMonths
}
from
"date-fns"
;
import
{
addMonths
,
format
,
getDay
,
startOfMonth
,
subMonths
}
from
"date-fns"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronLeft
,
ChevronRight
}
from
"lucide-react"
;
import
{
ChevronLeft
,
ChevronRight
}
from
"lucide-react"
;
...
@@ -11,41 +8,32 @@ import { useMemo, useState } from "react";
...
@@ -11,41 +8,32 @@ import { useMemo, useState } from "react";
const
weekDays
=
[
"CN"
,
"T2"
,
"T3"
,
"T4"
,
"T5"
,
"T6"
,
"T7"
];
const
weekDays
=
[
"CN"
,
"T2"
,
"T3"
,
"T4"
,
"T5"
,
"T6"
,
"T7"
];
const
eventItems
=
getAdminNewsSeed
()
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
!
item
.
is_hidden
&&
item
.
started_at
,
)
.
sort
(
(
left
,
right
)
=>
new
Date
(
left
.
started_at
).
getTime
()
-
new
Date
(
right
.
started_at
).
getTime
(),
);
function
isTrainingEvent
(
item
:
AdminNewsItem
)
{
return
item
.
tagsearch_values
.
some
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
"đào tạo"
));
}
function
EventsCalendar
()
{
function
EventsCalendar
()
{
const
firstEventDate
=
eventItems
[
0
]?.
started_at
const
{
eventPosts
}
=
useHomePosts
();
?
new
Date
(
eventItems
[
0
].
started_at
)
const
firstEventDate
=
eventPosts
[
0
]?.
startedAt
?
new
Date
(
eventPosts
[
0
].
startedAt
)
:
new
Date
(
"2026-11-01T00:00:00"
);
:
new
Date
(
"2026-11-01T00:00:00"
);
const
[
currentMonth
,
setCurrentMonth
]
=
useState
(
const
[
currentMonth
,
setCurrentMonth
]
=
useState
(
new
Date
(
firstEventDate
.
getFullYear
(),
firstEventDate
.
getMonth
(),
1
),
new
Date
(
firstEventDate
.
getFullYear
(),
firstEventDate
.
getMonth
(),
1
),
);
);
const
isTrainingEvent
=
(
item
:
HomePostItem
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
name
.
toLowerCase
().
includes
(
"đào tạo"
),
);
const
monthEvents
=
useMemo
(
const
monthEvents
=
useMemo
(
()
=>
()
=>
event
Item
s
.
filter
((
item
)
=>
{
event
Post
s
.
filter
((
item
)
=>
{
const
date
=
new
Date
(
item
.
started
_a
t
);
const
date
=
new
Date
(
item
.
started
A
t
);
return
(
return
(
date
.
getMonth
()
===
currentMonth
.
getMonth
()
&&
date
.
getMonth
()
===
currentMonth
.
getMonth
()
&&
date
.
getFullYear
()
===
currentMonth
.
getFullYear
()
date
.
getFullYear
()
===
currentMonth
.
getFullYear
()
);
);
}),
}),
[
currentMonth
],
[
currentMonth
,
eventPosts
],
);
);
const
days
=
useMemo
(()
=>
{
const
days
=
useMemo
(()
=>
{
...
@@ -62,10 +50,10 @@ function EventsCalendar() {
...
@@ -62,10 +50,10 @@ function EventsCalendar() {
},
[
currentMonth
]);
},
[
currentMonth
]);
const
eventMap
=
useMemo
(()
=>
{
const
eventMap
=
useMemo
(()
=>
{
const
map
=
new
Map
<
string
,
AdminNews
Item
[]
>
();
const
map
=
new
Map
<
string
,
HomePost
Item
[]
>
();
monthEvents
.
forEach
((
item
)
=>
{
monthEvents
.
forEach
((
item
)
=>
{
const
key
=
dayjs
(
item
.
started
_a
t
).
format
(
"YYYY-MM-DD"
);
const
key
=
dayjs
(
item
.
started
A
t
).
format
(
"YYYY-MM-DD"
);
const
existing
=
map
.
get
(
key
)
??
[];
const
existing
=
map
.
get
(
key
)
??
[];
existing
.
push
(
item
);
existing
.
push
(
item
);
map
.
set
(
key
,
existing
);
map
.
set
(
key
,
existing
);
...
...
src/app/(main)/(home)/components/events/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
eventItems
=
getAdminNewsSeed
()
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
item
.
header_category_id
===
"activity-events"
&&
!
item
.
is_hidden
&&
item
.
started_at
,
)
.
sort
(
(
left
,
right
)
=>
new
Date
(
left
.
started_at
).
getTime
()
-
new
Date
(
right
.
started_at
).
getTime
(),
);
function
formatEventDate
(
item
:
AdminNewsItem
)
{
return
dayjs
(
item
.
started_at
||
item
.
published_at
||
item
.
created_at
).
format
(
"DD/MM/YYYY"
);
}
function
Events
()
{
function
Events
()
{
const
{
eventPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
eventItems
=
eventPosts
;
const
[
featuredEvent
,
...
sideEvents
]
=
eventItems
;
const
[
featuredEvent
,
...
sideEvents
]
=
eventItems
;
const
sideSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
sideEvents
[
index
]
??
null
);
if
(
!
featuredEvent
)
return
null
;
const
eventsLink
=
categoryLinks
.
get
(
categoryNames
.
suKien
.
toLowerCase
())
??
"/hoat-dong/su-kien"
;
return
(
return
(
<
div
className=
"flex-1 rounded-[28px] 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=
"flex-1 rounded-[28px] 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"
>
...
@@ -41,7 +24,7 @@ function Events() {
...
@@ -41,7 +24,7 @@ function Events() {
</
div
>
</
div
>
<
Link
<
Link
href=
"/hoat-dong/su-kien"
href=
{
eventsLink
}
className=
"pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
className=
"pt-1.5 text-sm font-semibold text-[#ffd34f] transition-colors hover:text-white"
>
>
Xem sự kiện
Xem sự kiện
...
@@ -49,8 +32,9 @@ function Events() {
...
@@ -49,8 +32,9 @@ function Events() {
</
div
>
</
div
>
<
div
className=
"grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]"
>
<
div
className=
"grid items-stretch gap-3 xl:grid-cols-[minmax(0,1.02fr)_minmax(270px,0.98fr)]"
>
{
featuredEvent
?
(
<
Link
<
Link
href=
"/hoat-dong/su-kien"
href=
{
featuredEvent
.
externalLink
}
className=
"flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
className=
"flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
>
<
div
className=
"h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]"
>
<
div
className=
"h-[220px] overflow-hidden md:h-[235px] xl:h-[248px]"
>
...
@@ -67,15 +51,29 @@ function Events() {
...
@@ -67,15 +51,29 @@ function Events() {
<
h3
className=
"line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]"
>
<
h3
className=
"line-clamp-2 text-[16px] font-extrabold uppercase leading-[1.28] text-[#22459b] md:text-[18px]"
>
{
featuredEvent
.
title
}
{
featuredEvent
.
title
}
</
h3
>
</
h3
>
<
p
className=
"mt-1.5 text-[13px] text-[#90a0bd]"
>
{
formatEventDate
(
featuredEvent
)
}
</
p
>
<
p
className=
"mt-1.5 text-[13px] text-[#90a0bd]"
>
{
dayjs
(
featuredEvent
.
startedAt
||
featuredEvent
.
publishedAt
||
featuredEvent
.
createdAt
,
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
</
Link
>
)
:
(
<
div
className=
"flex h-full flex-col overflow-hidden rounded-[22px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.12)]"
>
<
div
className=
"h-[220px] bg-[#d7e3f9] md:h-[235px] xl:h-[248px]"
/>
<
div
className=
"space-y-2 p-3 pt-2.5"
>
<
div
className=
"h-6 w-5/6 rounded bg-[#e7eefb]"
/>
<
div
className=
"h-4 w-24 rounded bg-[#eef3fb]"
/>
</
div
>
</
div
>
)
}
<
div
className=
"flex h-full flex-col gap-3"
>
<
div
className=
"flex h-full flex-col gap-3"
>
{
sideEvents
.
slice
(
0
,
4
).
map
((
item
)
=>
(
{
sideSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
<
Link
key=
{
item
.
id
}
key=
{
item
.
id
}
href=
"/hoat-dong/su-kien"
href=
{
item
.
externalLink
}
className=
"flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
className=
"flex flex-1 items-center gap-3 rounded-[18px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
>
<
div
className=
"h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]"
>
<
div
className=
"h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[12px]"
>
...
@@ -92,10 +90,26 @@ function Events() {
...
@@ -92,10 +90,26 @@ function Events() {
<
h4
className=
"line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white"
>
<
h4
className=
"line-clamp-2 text-[15px] font-semibold leading-[1.35] text-white"
>
{
item
.
title
}
{
item
.
title
}
</
h4
>
</
h4
>
<
p
className=
"mt-1 text-[12px] text-white/78"
>
{
formatEventDate
(
item
)
}
</
p
>
<
p
className=
"mt-1 text-[12px] text-white/78"
>
{
dayjs
(
item
.
startedAt
||
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
,
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
</
Link
>
))
}
)
:
(
<
div
key=
{
`event-placeholder-${index}`
}
className=
"flex flex-1 items-center gap-3 rounded-[18px] 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 rounded-[12px] bg-white/20"
/>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"h-5 w-5/6 rounded bg-white/25"
/>
<
div
className=
"mt-2 h-3 w-20 rounded bg-white/20"
/>
</
div
>
</
div
>
),
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
src/app/(main)/(home)/components/featured-news/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
{
getHeaderCategorySeed
}
from
"@/mockdata/header-config"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
,
Mail
,
Phone
}
from
"lucide-react"
;
import
{
ChevronRight
,
Mail
,
Phone
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
FALLBACK_CATEGORY_LINK
=
"/hoat-dong/tin-tuc"
;
const
FALLBACK_CATEGORY_LINK
=
"/hoat-dong/tin-tuc"
;
const
headerCategoryMap
=
new
Map
(
getHeaderCategorySeed
().
map
((
item
)
=>
[
item
.
id
,
item
.
static_link
]),
);
const
FEATURED_OVERVIEW_LINK
=
headerCategoryMap
.
get
(
"activity-news"
)
??
FALLBACK_CATEGORY_LINK
;
function
getFeaturedNewsItems
(
items
:
AdminNewsItem
[])
{
return
items
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
item
.
is_featured
&&
!
item
.
is_hidden
&&
Boolean
(
item
.
thumbnail
?.
url
),
)
.
slice
(
0
,
3
);
}
function
getNewsLink
(
item
:
AdminNewsItem
)
{
return
headerCategoryMap
.
get
(
item
.
header_category_id
)
??
FALLBACK_CATEGORY_LINK
;
}
function
getBadgeLabel
(
item
:
AdminNewsItem
)
{
if
(
item
.
header_category_id
===
"activity-events"
)
return
"Sự kiện"
;
const
firstTag
=
item
.
tagsearch_values
.
find
(
Boolean
);
if
(
firstTag
)
return
firstTag
;
return
"Tin VCCI"
;
}
const
featuredNewsItems
=
getFeaturedNewsItems
(
getAdminNewsSeed
());
function
FeaturedNews
()
{
function
FeaturedNews
()
{
const
{
featuredPosts
,
categoryNames
,
categoryLinks
}
=
useHomePosts
();
const
featuredNewsItems
=
featuredPosts
.
slice
(
0
,
3
);
const
[
primaryItem
,
...
secondaryItems
]
=
featuredNewsItems
;
const
[
primaryItem
,
...
secondaryItems
]
=
featuredNewsItems
;
const
secondarySlots
=
Array
.
from
({
length
:
2
},
(
_
,
index
)
=>
secondaryItems
[
index
]
??
null
);
if
(
!
primaryItem
)
return
null
;
const
featuredOverviewLink
=
categoryLinks
.
get
(
categoryNames
.
tinVcci
.
toLowerCase
())
??
FALLBACK_CATEGORY_LINK
;
return
(
return
(
<
section
className=
"py-8 md:py-10"
>
<
section
className=
"py-8 md:py-10"
>
...
@@ -61,7 +28,7 @@ function FeaturedNews() {
...
@@ -61,7 +28,7 @@ function FeaturedNews() {
</
div
>
</
div
>
<
Link
<
Link
href=
{
FEATURED_OVERVIEW_LINK
}
href=
{
featuredOverviewLink
}
className=
"inline-flex items-center gap-2 pt-2 text-base font-semibold text-[#2b56c0] transition-colors hover:text-[#173f9f]"
className=
"inline-flex items-center gap-2 pt-2 text-base font-semibold text-[#2b56c0] transition-colors hover:text-[#173f9f]"
>
>
<
span
>
Xem tất cả
</
span
>
<
span
>
Xem tất cả
</
span
>
...
@@ -70,8 +37,9 @@ function FeaturedNews() {
...
@@ -70,8 +37,9 @@ function FeaturedNews() {
</
div
>
</
div
>
<
div
className=
"grid gap-5 xl:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]"
>
<
div
className=
"grid gap-5 xl:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]"
>
{
primaryItem
?
(
<
Link
<
Link
href=
{
getNewsLink
(
primaryItem
)
}
href=
{
primaryItem
.
externalLink
}
className=
"group relative block min-h-[260px] overflow-hidden rounded-[24px] bg-[#0d2f5f] shadow-[0_18px_38px_rgba(28,52,120,0.22)] md:min-h-[320px] xl:min-h-[350px]"
className=
"group relative block min-h-[260px] overflow-hidden rounded-[24px] bg-[#0d2f5f] shadow-[0_18px_38px_rgba(28,52,120,0.22)] md:min-h-[320px] xl:min-h-[350px]"
>
>
<
div
className=
"relative h-full min-h-[260px] md:min-h-[320px] xl:min-h-[350px]"
>
<
div
className=
"relative h-full min-h-[260px] md:min-h-[320px] xl:min-h-[350px]"
>
...
@@ -86,7 +54,7 @@ function FeaturedNews() {
...
@@ -86,7 +54,7 @@ function FeaturedNews() {
<
div
className=
"relative flex h-full flex-col justify-end p-4 md:p-5"
>
<
div
className=
"relative flex h-full flex-col justify-end p-4 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]"
>
<
span
className=
"mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]"
>
{
getBadgeLabel
(
primaryItem
)
}
{
primaryItem
.
categories
[
0
]?.
name
||
"Tin nổi bật"
}
</
span
>
</
span
>
<
h3
className=
"max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]"
>
<
h3
className=
"max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]"
>
...
@@ -94,18 +62,30 @@ function FeaturedNews() {
...
@@ -94,18 +62,30 @@ function FeaturedNews() {
</
h3
>
</
h3
>
<
p
className=
"mt-2 text-base font-medium text-white/78 md:text-[17px]"
>
<
p
className=
"mt-2 text-base font-medium text-white/78 md:text-[17px]"
>
{
dayjs
(
primaryItem
.
published_at
||
primaryItem
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
{
dayjs
(
primaryItem
.
publishedAt
||
primaryItem
.
createdAt
).
format
(
"DD/MM/YYYY"
,
)
}
</
p
>
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
</
Link
>
</
Link
>
)
:
(
<
div
className=
"relative min-h-[260px] overflow-hidden rounded-[24px] bg-[#e9eef8] shadow-[0_18px_38px_rgba(28,52,120,0.12)] md:min-h-[320px] xl:min-h-[350px]"
>
<
div
className=
"flex h-full min-h-[260px] flex-col justify-end p-4 md:min-h-[320px] md:p-5 xl:min-h-[350px]"
>
<
span
className=
"mb-2 h-8 w-28 rounded-[10px] bg-white/80"
/>
<
div
className=
"h-8 w-3/4 rounded bg-white/90 md:h-10"
/>
<
div
className=
"mt-2 h-5 w-28 rounded bg-white/70"
/>
</
div
>
</
div
>
)
}
<
div
className=
"grid gap-4"
>
<
div
className=
"grid gap-4"
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
secondaryItems
.
map
((
item
)
=>
(
{
secondarySlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
<
Link
key=
{
item
.
id
}
key=
{
item
.
id
}
href=
{
getNewsLink
(
item
)
}
href=
{
item
.
externalLink
}
className=
"group relative block min-h-[195px] overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] xl:min-h-[215px]"
className=
"group relative block min-h-[195px] overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] xl:min-h-[215px]"
>
>
<
div
className=
"relative h-full min-h-[195px] md:min-h-[205px] xl:min-h-[215px]"
>
<
div
className=
"relative h-full min-h-[195px] md:min-h-[205px] xl:min-h-[215px]"
>
...
@@ -120,7 +100,7 @@ function FeaturedNews() {
...
@@ -120,7 +100,7 @@ function FeaturedNews() {
<
div
className=
"relative flex h-full flex-col justify-end p-3.5"
>
<
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]"
>
<
span
className=
"mb-2 inline-flex w-fit rounded-[10px] bg-[#ffc400] px-3 py-1 text-sm font-bold text-[#1d3f90]"
>
{
getBadgeLabel
(
item
)
}
{
item
.
categories
[
0
]?.
name
||
"Tin nổi bật"
}
</
span
>
</
span
>
<
h4
className=
"line-clamp-2 text-[16px] font-bold leading-[1.32] text-white md:text-[17px]"
>
<
h4
className=
"line-clamp-2 text-[16px] font-bold leading-[1.32] text-white md:text-[17px]"
>
...
@@ -128,12 +108,26 @@ function FeaturedNews() {
...
@@ -128,12 +108,26 @@ function FeaturedNews() {
</
h4
>
</
h4
>
<
p
className=
"mt-1.5 text-[15px] font-medium text-white/78 md:text-base"
>
<
p
className=
"mt-1.5 text-[15px] font-medium text-white/78 md:text-base"
>
{
dayjs
(
item
.
published_at
||
item
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
,
)
}
</
p
>
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
</
Link
>
</
Link
>
))
}
)
:
(
<
div
key=
{
`featured-placeholder-${index}`
}
className=
"min-h-[195px] rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] md:min-h-[205px] xl:min-h-[215px]"
>
<
div
className=
"flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-[205px] xl:min-h-[215px]"
>
<
span
className=
"mb-2 h-7 w-24 rounded-[10px] bg-white/80"
/>
<
div
className=
"h-6 w-5/6 rounded bg-white/90"
/>
<
div
className=
"mt-2 h-4 w-24 rounded bg-white/70"
/>
</
div
>
</
div
>
),
)
}
</
div
>
</
div
>
<
div
className=
"overflow-hidden rounded-[28px] bg-linear-to-r from-[#214b95] to-[#2b66bb] px-5 py-5 text-white shadow-[0_18px_38px_rgba(28,52,120,0.2)] md:px-7"
>
<
div
className=
"overflow-hidden rounded-[28px] bg-linear-to-r from-[#214b95] to-[#2b66bb] px-5 py-5 text-white shadow-[0_18px_38px_rgba(28,52,120,0.2)] md:px-7"
>
...
...
src/app/(main)/(home)/components/members/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
memberImages
from
"@/constants/memberImages"
;
import
memberImages
from
"@/constants/memberImages"
;
import
{
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
memberConnectionItems
=
getAdminNewsSeed
()
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
!
item
.
is_hidden
&&
(
item
.
category_ids
.
includes
(
"cat-member-connection"
)
||
item
.
tagsearch_values
.
some
((
tag
)
=>
tag
.
toLowerCase
().
includes
(
"kết nối hội viên"
))),
)
.
sort
(
(
left
,
right
)
=>
new
Date
(
right
.
published_at
||
right
.
created_at
).
getTime
()
-
new
Date
(
left
.
published_at
||
left
.
created_at
).
getTime
(),
);
function
Members
()
{
function
Members
()
{
const
featuredConnection
=
memberConnectionItems
[
0
];
const
{
memberConnectionPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
featuredConnection
=
memberConnectionPosts
[
0
];
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
ketNoiHoiVien
.
toLowerCase
())
??
"/hoi-vien/ket-noi-hoi-vien"
;
return
(
return
(
<
section
className=
"flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"
>
<
section
className=
"flex flex-col gap-5 pb-8 xl:flex-row xl:items-stretch"
>
...
@@ -36,7 +23,7 @@ function Members() {
...
@@ -36,7 +23,7 @@ function Members() {
</
div
>
</
div
>
<
Link
<
Link
href=
"/danh-ba-hoi-vien"
href=
{
sectionLink
}
className=
"pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
className=
"pt-1 text-sm font-semibold text-[#1e2f5e] transition-colors hover:text-[#20449a]"
>
>
Xem thêm
Xem thêm
...
@@ -77,7 +64,7 @@ function Members() {
...
@@ -77,7 +64,7 @@ function Members() {
{
featuredConnection
?
(
{
featuredConnection
?
(
<
Link
<
Link
href=
"/danh-ba-hoi-vien"
href=
{
featuredConnection
.
externalLink
}
className=
"block overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
className=
"block overflow-hidden rounded-[20px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
>
<
div
className=
"aspect-[1.25/1] overflow-hidden rounded-[20px]"
>
<
div
className=
"aspect-[1.25/1] overflow-hidden rounded-[20px]"
>
...
@@ -90,7 +77,11 @@ function Members() {
...
@@ -90,7 +77,11 @@ function Members() {
/>
/>
</
div
>
</
div
>
</
Link
>
</
Link
>
)
:
null
}
)
:
(
<
div
className=
"overflow-hidden rounded-[20px] bg-[#eef3fb] shadow-[0_16px_32px_rgba(31,59,124,0.08)]"
>
<
div
className=
"aspect-[1.25/1] rounded-[20px] bg-[#e3ebf8]"
/>
</
div
>
)
}
</
aside
>
</
aside
>
</
section
>
</
section
>
);
);
...
...
src/app/(main)/(home)/components/news/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
stripImagesAndHtml
from
"@/helpers/stripImageAndHtml"
;
import
stripImagesAndHtml
from
"@/helpers/stripImageAndHtml"
;
import
{
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
...
@@ -13,60 +10,32 @@ import { useMemo, useState } from "react";
...
@@ -13,60 +10,32 @@ import { useMemo, useState } from "react";
const
tabs
=
[
const
tabs
=
[
{
id
:
"all"
,
label
:
"Tất cả"
},
{
id
:
"all"
,
label
:
"Tất cả"
},
{
id
:
"tin-vcci"
,
label
:
"Tin VCCI"
},
{
id
:
"tin-vcci"
,
label
:
"Tin VCCI"
},
{
id
:
"tin-kinh-te"
,
label
:
"Tin Kinh
T
ế"
},
{
id
:
"tin-kinh-te"
,
label
:
"Tin Kinh
t
ế"
},
{
id
:
"chuyen-de"
,
label
:
"Chuyên
Đ
ề"
},
{
id
:
"chuyen-de"
,
label
:
"Chuyên
đ
ề"
},
];
];
const
allNewsItems
=
getAdminNewsSeed
().
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
!
item
.
is_hidden
,
);
function
getTabLabel
(
item
:
AdminNewsItem
)
{
const
tags
=
item
.
tagsearch_values
.
map
((
tag
)
=>
tag
.
toLowerCase
());
if
(
tags
.
some
((
tag
)
=>
tag
.
includes
(
"kinh tế"
)
||
tag
.
includes
(
"vĩ mô"
)))
{
return
"Tin Kinh Tế"
;
}
if
(
tags
.
some
((
tag
)
=>
tag
.
includes
(
"chuyên đề"
)
||
tag
.
includes
(
"cẩm nang"
)))
{
return
"Chuyên Đề"
;
}
return
"Tin VCCI"
;
}
function
matchesTab
(
item
:
AdminNewsItem
,
tab
:
string
)
{
if
(
tab
===
"all"
)
return
true
;
const
tags
=
item
.
tagsearch_values
.
map
((
value
)
=>
value
.
toLowerCase
());
if
(
tab
===
"tin-vcci"
)
{
return
tags
.
some
((
tag
)
=>
tag
.
includes
(
"tin vcci"
)
||
tag
.
includes
(
"hợp tác"
));
}
if
(
tab
===
"tin-kinh-te"
)
{
return
tags
.
some
((
tag
)
=>
tag
.
includes
(
"kinh tế"
)
||
tag
.
includes
(
"vĩ mô"
));
}
if
(
tab
===
"chuyen-de"
)
{
return
tags
.
some
((
tag
)
=>
tag
.
includes
(
"chuyên đề"
)
||
tag
.
includes
(
"cẩm nang"
));
}
return
true
;
}
function
News
()
{
function
News
()
{
const
[
tab
,
setTab
]
=
useState
(
"all"
);
const
[
tab
,
setTab
]
=
useState
(
"all"
);
const
{
newsTabs
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
filteredItems
=
useMemo
(
const
filteredItems
=
useMemo
(()
=>
{
()
=>
allNewsItems
.
filter
((
item
)
=>
matchesTab
(
item
,
tab
)),
if
(
tab
===
"all"
)
return
newsTabs
.
all
;
[
tab
],
if
(
tab
===
"tin-kinh-te"
)
return
newsTabs
.
tinKinhTe
;
);
if
(
tab
===
"chuyen-de"
)
return
newsTabs
.
chuyenDe
;
return
newsTabs
.
tinVcci
;
},
[
newsTabs
,
tab
]);
const
featuredArticle
=
filteredItems
[
0
]
??
allNewsItems
[
0
];
const
featuredArticle
=
filteredItems
[
0
]
??
newsTabs
.
all
[
0
];
const
listArticles
=
filteredItems
.
slice
(
1
,
5
);
const
listArticles
=
filteredItems
.
slice
(
1
,
5
);
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
listArticles
[
index
]
??
null
);
if
(
!
featuredArticle
)
return
null
;
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
(
return
(
<
div
className=
"flex-1"
>
<
div
className=
"flex-1"
>
...
@@ -102,8 +71,9 @@ function News() {
...
@@ -102,8 +71,9 @@ function News() {
<
div
className=
"grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]"
>
<
div
className=
"grid gap-4 xl:grid-cols-[minmax(0,1.02fr)_minmax(320px,0.98fr)]"
>
<
div
>
<
div
>
{
featuredArticle
?
(
<
Link
<
Link
href=
"/hoat-dong/tin-tuc"
href=
{
featuredArticle
.
externalLink
}
className=
"block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
className=
"block h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
>
<
div
className=
"aspect-[1.75/1] overflow-hidden"
>
<
div
className=
"aspect-[1.75/1] overflow-hidden"
>
...
@@ -118,7 +88,7 @@ function News() {
...
@@ -118,7 +88,7 @@ function News() {
<
div
className=
"space-y-1.5 p-3"
>
<
div
className=
"space-y-1.5 p-3"
>
<
span
className=
"inline-flex text-[14px] font-bold text-[#e2a500]"
>
<
span
className=
"inline-flex text-[14px] font-bold text-[#e2a500]"
>
{
getTabLabel
(
featuredArticle
)
}
{
featuredArticle
.
categories
[
0
]?.
name
||
"Tin tức"
}
</
span
>
</
span
>
<
h3
className=
"line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]"
>
<
h3
className=
"line-clamp-2 text-[16px] font-bold leading-[1.28] text-[#20408f] md:text-[17px]"
>
...
@@ -130,32 +100,65 @@ function News() {
...
@@ -130,32 +100,65 @@ function News() {
</
p
>
</
p
>
<
p
className=
"text-[14px] text-[#8a9bb6]"
>
<
p
className=
"text-[14px] text-[#8a9bb6]"
>
{
dayjs
(
featuredArticle
.
published_at
||
featuredArticle
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
{
dayjs
(
featuredArticle
.
publishedAt
||
featuredArticle
.
createdAt
).
format
(
"DD/MM/YYYY"
,
)
}
</
p
>
</
p
>
</
div
>
</
div
>
</
Link
>
</
Link
>
)
:
(
<
div
className=
"h-full overflow-hidden rounded-[22px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<
div
className=
"aspect-[1.75/1] bg-[#eef3fb]"
/>
<
div
className=
"space-y-2 p-3"
>
<
div
className=
"h-5 w-24 rounded bg-[#eef3fb]"
/>
<
div
className=
"h-6 w-5/6 rounded bg-[#eef3fb]"
/>
<
div
className=
"h-4 w-full rounded bg-[#f4f7fb]"
/>
<
div
className=
"h-4 w-3/4 rounded bg-[#f4f7fb]"
/>
<
div
className=
"h-4 w-24 rounded bg-[#eef3fb]"
/>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
<
div
className=
"xl:flex xl:h-full xl:flex-col"
>
<
div
className=
"xl:flex xl:h-full xl:flex-col"
>
<
div
className=
"space-y-3 xl:flex xl:flex-1 xl:flex-col"
>
<
div
className=
"space-y-3 xl:flex xl:flex-1 xl:flex-col"
>
{
listArticles
.
map
((
news
)
=>
(
{
listSlots
.
map
((
news
,
index
)
=>
news
?
(
<
Link
<
Link
key=
{
news
.
id
}
key=
{
news
.
id
}
href=
"/hoat-dong/tin-tuc"
href=
{
news
.
externalLink
}
className=
"block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
className=
"block rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)] xl:flex-1"
>
>
<
h4
className=
"line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]"
>
<
h4
className=
"line-clamp-2 text-[15px] font-bold leading-[1.28] text-[#21408f]"
>
{
news
.
title
}
{
news
.
title
}
</
h4
>
</
h4
>
<
p
className=
"mt-1 text-[13px] text-[#8a9bb6]"
>
<
p
className=
"mt-1 text-[13px] text-[#8a9bb6]"
>
{
dayjs
(
news
.
published_at
||
news
.
created_a
t
).
format
(
"DD/MM/YYYY"
)
}
{
dayjs
(
news
.
publishedAt
||
news
.
createdA
t
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
p
>
</
Link
>
</
Link
>
))
}
)
:
(
<
div
key=
{
`news-placeholder-${index}`
}
className=
"rounded-[18px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.06)] xl:flex-1"
>
<
div
className=
"h-5 w-5/6 rounded bg-[#eef3fb]"
/>
<
div
className=
"mt-2 h-4 w-24 rounded bg-[#f4f7fb]"
/>
</
div
>
</
div
>
),
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"mt-3 flex justify-end"
>
<
Link
href=
{
overviewLink
}
className=
"text-sm font-semibold text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
Xem tất cả
</
Link
>
</
div
>
</
div
>
);
);
}
}
...
...
src/app/(main)/(home)/components/policies-and-laws/index.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
type
AdminNewsItem
,
getAdminNewsSeed
,
}
from
"@/mockdata/admin-news"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
{
ChevronRight
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
policyItems
=
getAdminNewsSeed
()
.
filter
(
(
item
)
=>
item
.
type
===
"tintuc"
&&
!
item
.
is_hidden
&&
(
item
.
category_ids
.
includes
(
"cat-policy-law"
)
||
item
.
category_ids
.
includes
(
"cat-policy"
)
||
item
.
tagsearch_values
.
some
((
tag
)
=>
{
const
normalized
=
tag
.
toLowerCase
();
return
normalized
.
includes
(
"chính sách"
)
||
normalized
.
includes
(
"pháp luật"
);
})),
)
.
sort
(
(
left
,
right
)
=>
new
Date
(
right
.
published_at
||
right
.
created_at
).
getTime
()
-
new
Date
(
left
.
published_at
||
left
.
created_at
).
getTime
(),
);
function
formatPublishDate
(
item
:
AdminNewsItem
)
{
return
dayjs
(
item
.
published_at
||
item
.
created_at
).
format
(
"DD/MM/YYYY"
);
}
function
PolicyAndLaws
()
{
function
PolicyAndLaws
()
{
const
{
policyPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
policyItems
=
policyPosts
;
const
[
featuredItem
,
...
listItems
]
=
policyItems
;
const
[
featuredItem
,
...
listItems
]
=
policyItems
;
const
listSlots
=
[
featuredItem
,
...
listItems
.
slice
(
0
,
2
)];
if
(
!
featuredItem
)
return
null
;
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
chinhSachPhapLuat
.
toLowerCase
())
??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat"
;
return
(
return
(
<
section
className=
"flex-1"
>
<
section
className=
"flex-1"
>
...
@@ -46,7 +25,7 @@ function PolicyAndLaws() {
...
@@ -46,7 +25,7 @@ function PolicyAndLaws() {
</
div
>
</
div
>
<
Link
<
Link
href=
"/thong-tin-truyen-thong/phap-luat"
href=
{
sectionLink
}
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
className=
"text-[#24469c] transition-colors hover:text-[#1b55a1]"
>
>
<
ChevronRight
className=
"h-5 w-5"
/>
<
ChevronRight
className=
"h-5 w-5"
/>
...
@@ -54,10 +33,11 @@ function PolicyAndLaws() {
...
@@ -54,10 +33,11 @@ function PolicyAndLaws() {
</
div
>
</
div
>
<
div
className=
"space-y-2.5"
>
<
div
className=
"space-y-2.5"
>
{
[
featuredItem
,
...
listItems
.
slice
(
0
,
2
)].
map
((
item
,
index
)
=>
(
{
listSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
<
Link
key=
{
item
.
id
}
key=
{
item
.
id
}
href=
"/thong-tin-truyen-thong/phap-luat"
href=
{
item
.
externalLink
}
className=
{
`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
className=
{
`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
index === 0 ? "pt-0.5" : ""
}`
}
}`
}
...
@@ -67,10 +47,24 @@ function PolicyAndLaws() {
...
@@ -67,10 +47,24 @@ function PolicyAndLaws() {
<
h3
className=
"line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]"
>
<
h3
className=
"line-clamp-2 text-[15px] leading-[1.45] text-[#264798] md:text-[16px]"
>
{
item
.
title
}
{
item
.
title
}
</
h3
>
</
h3
>
<
p
className=
"mt-1.5 text-[13px] text-[#9aa8c1]"
>
{
formatPublishDate
(
item
)
}
</
p
>
<
p
className=
"mt-1.5 text-[13px] text-[#9aa8c1]"
>
{
dayjs
(
item
.
publishedAt
||
item
.
createdAt
).
format
(
"DD/MM/YYYY"
)
}
</
p
>
</
div
>
</
div
>
</
Link
>
</
Link
>
))
}
)
:
(
<
div
key=
{
`policy-placeholder-${index}`
}
className=
{
`flex gap-3 rounded-[14px] 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 rounded bg-[#eef3fb]"
/>
<
div
className=
"mt-1.5 h-4 w-24 rounded bg-[#f4f7fb]"
/>
</
div
>
</
div
>
),
)
}
</
div
>
</
div
>
</
section
>
</
section
>
);
);
...
...
src/app/(main)/(home)/lib/use-home-posts.ts
0 → 100644
View file @
82063bf1
"use client"
;
import
*
as
React
from
"react"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
Links
from
"@/links"
;
type
RawHomeCategory
=
{
id
?:
string
|
null
;
name
?:
string
|
null
;
url
?:
string
|
null
;
type
?:
string
|
null
;
};
type
RawHomeThumbnail
=
{
path
?:
string
|
null
;
original
?:
string
|
null
;
};
type
RawHomePost
=
{
id
?:
string
|
null
;
title
?:
string
|
null
;
external_link
?:
string
|
null
;
summary
?:
string
|
null
;
content
?:
string
|
null
;
release_at
?:
string
|
null
;
published_at
?:
string
|
null
;
created_at
?:
string
|
null
;
started_at
?:
string
|
null
;
expired_at
?:
string
|
null
;
is_featured
?:
boolean
|
null
;
is_hidden
?:
boolean
|
null
;
is_active
?:
boolean
|
null
;
status
?:
string
|
null
;
type
?:
string
|
null
;
categories
?:
RawHomeCategory
[]
|
null
;
thumbnail
?:
RawHomeThumbnail
|
null
;
};
type
HomeEnvelope
<
T
>
=
{
responseData
?:
T
;
};
type
HomePagedResult
<
T
>
=
{
rows
?:
T
[];
};
export
type
HomePostCategory
=
{
id
:
string
;
name
:
string
;
url
:
string
;
type
:
string
;
};
export
type
HomePostItem
=
{
id
:
string
;
title
:
string
;
externalLink
:
string
;
summary
:
string
;
createdAt
:
string
;
publishedAt
:
string
;
startedAt
:
string
;
expiredAt
:
string
;
isFeatured
:
boolean
;
isHidden
:
boolean
;
isActive
:
boolean
;
status
:
string
;
type
:
string
;
categories
:
HomePostCategory
[];
thumbnail
:
{
url
:
string
;
alt
:
string
;
}
|
null
;
};
const
HOME_POSTS_QUERY_KEY
=
[
"home-page-posts"
]
as
const
;
const
HOME_CATEGORY_NAMES
=
{
tinVcci
:
"Tin VCCI"
,
tinKinhTe
:
"Tin Kinh tế"
,
chuyenDe
:
"Chuyên đề"
,
suKien
:
"Sự kiện"
,
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
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
resolveAssetUrl
=
(
value
?:
string
|
null
)
=>
{
const
trimmed
=
value
?.
trim
();
if
(
!
trimmed
)
return
"/thumbnail.png"
;
if
(
trimmed
.
startsWith
(
"http://"
)
||
trimmed
.
startsWith
(
"https://"
))
return
trimmed
;
if
(
trimmed
.
startsWith
(
"/"
))
{
return
`
${
Links
.
imageEndpoint
.
replace
(
/
\/
+$/
,
""
)}${
trimmed
}
`
;
}
return
`
${
Links
.
imageEndpoint
}${
trimmed
.
replace
(
/^
\/
+/
,
""
)}
`
;
};
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
isVisibleNewsPost
=
(
item
:
HomePostItem
)
=>
{
if
(
item
.
type
&&
item
.
type
!==
"news"
)
return
false
;
if
(
item
.
isHidden
)
return
false
;
if
(
!
item
.
isActive
)
return
false
;
if
(
item
.
status
&&
item
.
status
!==
"published"
)
return
false
;
return
true
;
};
async
function
fetchHomePosts
()
{
const
query
=
new
URLSearchParams
({
page
:
"1"
,
pageSize
:
"200"
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
response
=
await
useCustomClient
<
HomeEnvelope
<
HomePagedResult
<
RawHomePost
>>>
(
`/post?
${
query
.
toString
()}
`
,
);
const
rows
=
response
.
responseData
?.
rows
??
[];
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
),
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
=
normalizeLink
(
item
.
external_link
||
(
title
?
`/
${
title
}
`
:
undefined
),
"#"
,
);
return
{
id
:
String
(
item
.
id
??
""
),
title
,
externalLink
,
summary
:
String
(
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
??
""
),
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
??
""
),
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
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
tinVcci
)),
),
[
posts
],
);
const
tinKinhTePosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
tinKinhTe
)),
),
[
posts
],
);
const
chuyenDePosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
chuyenDe
)),
),
[
posts
],
);
const
featuredPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
allPosts
.
filter
((
item
)
=>
item
.
isFeatured
&&
!
item
.
isHidden
),
),
[
allPosts
],
);
const
eventPosts
=
React
.
useMemo
(
()
=>
sortByEventStartAsc
(
posts
.
filter
(
(
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
suKien
)
&&
Boolean
(
item
.
startedAt
),
),
),
[
posts
],
);
const
businessPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
coHoiKinhDoanh
),
),
),
[
posts
],
);
const
policyPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
chinhSachPhapLuat
),
),
),
[
posts
],
);
const
memberConnectionPosts
=
React
.
useMemo
(
()
=>
sortByPublishedDesc
(
posts
.
filter
((
item
)
=>
matchesCategoryName
(
item
,
HOME_CATEGORY_NAMES
.
ketNoiHoiVien
),
),
),
[
posts
],
);
const
allNewsPosts
=
React
.
useMemo
(
()
=>
uniquePosts
([...
tinVcciPosts
,
...
tinKinhTePosts
,
...
chuyenDePosts
]),
[
chuyenDePosts
,
tinKinhTePosts
,
tinVcciPosts
],
);
return
{
...
query
,
allPosts
,
posts
,
featuredPosts
,
eventPosts
,
businessPosts
,
policyPosts
,
memberConnectionPosts
,
newsTabs
:
{
all
:
allNewsPosts
,
tinVcci
:
tinVcciPosts
,
tinKinhTe
:
tinKinhTePosts
,
chuyenDe
:
chuyenDePosts
,
},
categoryLinks
,
categoryNames
:
HOME_CATEGORY_NAMES
,
};
}
src/app/(main)/[...slug]/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
{
useEffect
}
from
"react"
;
import
{
useEffect
,
useMemo
}
from
"react"
;
import
{
notFound
,
useParams
,
useRouter
}
from
"next/navigation"
;
import
{
notFound
,
useParams
,
useRouter
}
from
"next/navigation"
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
"@/api/endpoints/news-page-config"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
GetNewsPageConfigResponseType
}
from
"@/api/types/news-page-config"
;
import
{
Spinner
}
from
"@/components/ui"
;
// templates
import
InformationPage
from
"./templates/InformationPage"
;
import
ArticlePage
from
"./templates/ArticlePage"
;
import
ArticlePage
from
"./templates/ArticlePage"
;
import
ArticleDetailPage
from
"./templates/ArticleDetailPage"
;
import
ArticleDetailPage
from
"./templates/ArticleDetailPage"
;
import
EventPage
from
"./templates/EventPage"
;
import
InformationPage
from
"./templates/InformationPage"
;
import
EventDetailPage
from
"./templates/EventDetailPage"
;
import
{
import
{
Spinner
}
from
"@/components/ui"
;
fetchDynamicCategories
,
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
fetchDynamicPostByExternalLink
,
import
{
useGetNews
}
from
"@/api/endpoints/news"
;
fetchDynamicSinglePagePost
,
findDynamicCategoryByPath
,
findFirstChildCategory
,
findMenuCategoryForPost
,
}
from
"./templates/data"
;
export
default
function
DynamicPage
()
{
export
default
function
DynamicPage
()
{
const
params
=
useParams
();
const
params
=
useParams
();
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
const
path
=
slug
.
join
(
"/"
);
const
path
=
slug
.
join
(
"/"
);
const
routePath
=
`/
${
path
}
`
;
const
router
=
useRouter
();
const
router
=
useRouter
();
// query
const
categoryQuery
=
useQuery
({
const
{
data
:
news
}
=
useGetNews
<
GetNewsResponseType
>
(
queryKey
:
[
"dynamic-categories"
],
{
filters
:
`external_link==/
${
path
}
`
}
queryFn
:
fetchDynamicCategories
,
staleTime
:
5
*
60
*
1000
,
});
const
detailQuery
=
useQuery
({
queryKey
:
[
"dynamic-post-detail"
,
routePath
],
queryFn
:
()
=>
fetchDynamicPostByExternalLink
(
routePath
),
enabled
:
Boolean
(
routePath
),
staleTime
:
60
*
1000
,
});
const
matchedCategory
=
useMemo
(
()
=>
findDynamicCategoryByPath
(
categoryQuery
.
data
??
[],
routePath
),
[
categoryQuery
.
data
,
routePath
],
);
const
resolvedCategory
=
useMemo
(
()
=>
matchedCategory
??
findMenuCategoryForPost
(
detailQuery
.
data
??
null
,
categoryQuery
.
data
??
[]),
[
matchedCategory
,
detailQuery
.
data
,
categoryQuery
.
data
],
);
);
const
{
data
:
category
,
isLoading
,
isError
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
static_link
:
`/
${
path
}
`
,
const
singlePageQuery
=
useQuery
({
queryKey
:
[
"dynamic-single-page-post"
,
resolvedCategory
?.
id
],
queryFn
:
()
=>
fetchDynamicSinglePagePost
(
resolvedCategory
!
.
id
),
enabled
:
resolvedCategory
?.
type
===
"page"
,
staleTime
:
60
*
1000
,
});
});
// redirect to first child if has children
const
children
=
category
?.
responseData
?.
children
||
[];
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
category
)
return
;
if
(
!
matchedCategory
||
matchedCategory
.
type
!==
"category"
)
return
;
if
(
slug
.
length
===
1
&&
children
.
length
>
0
)
{
const
firstChild
=
children
[
0
];
if
(
firstChild
?.
static_link
)
{
router
.
push
(
firstChild
.
static_link
);
}
}
},
[
slug
,
category
,
children
,
router
]);
//template
const
firstChild
=
findFirstChildCategory
(
matchedCategory
,
categoryQuery
.
data
??
[]);
if
(
slug
[
0
]
===
"hoat-dong"
&&
slug
[
1
]
===
"su-kien"
)
{
if
(
slug
.
length
===
1
&&
firstChild
?.
url
)
{
if
(
slug
.
length
===
2
)
return
<
EventPage
/>;
router
.
replace
(
firstChild
.
url
);
if
(
slug
.
length
===
3
)
return
<
EventDetailPage
/>;
}
}
},
[
matchedCategory
,
categoryQuery
.
data
,
router
,
slug
.
length
]);
const
isLoading
=
categoryQuery
.
isLoading
||
detailQuery
.
isLoading
||
(
resolvedCategory
?.
type
===
"page"
&&
singlePageQuery
.
isLoading
);
if
(
news
?.
responseData
?.
count
==
0
&&
isLoading
)
{
if
(
isLoading
)
{
return
(
return
(
<
div
className=
"flex
justify-center items-center w-full h-64
"
>
<
div
className=
"flex
min-h-[50vh] items-center justify-center
"
>
<
Spinner
/>
<
Spinner
/>
</
div
>
</
div
>
);
);
}
}
if
(
news
&&
news
?.
responseData
.
rows
.
length
!==
0
)
{
if
(
detailQuery
.
data
)
{
return
<
ArticleDetailPage
data=
{
news
}
/>;
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
??
[]
}
/>
);
}
}
else
if
(
category
?.
responseData
.
is_article
==
true
)
{
if
(
resolvedCategory
?.
type
===
"news"
)
{
return
<
ArticlePage
/>;
return
(
<
ArticlePage
category=
{
resolvedCategory
}
allCategories=
{
categoryQuery
.
data
??
[]
}
/>
);
}
}
else
if
(
category
?.
responseData
.
is_article
==
false
)
{
if
(
resolvedCategory
?.
type
===
"category"
)
{
return
<
InformationPage
/>;
return
(
<
div
className=
"flex min-h-[50vh] items-center justify-center"
>
<
Spinner
/>
</
div
>
);
}
}
else
if
(
isError
)
{
return
notFound
();
return
notFound
();
}
}
}
src/app/(main)/[...slug]/templates/ArticleDetailPage.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
GetNewsPageConfigResponseType
}
from
"@/api/types/news-page-config"
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
"@/api/endpoints/news-page-config"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
useParams
}
from
"next/dist/client/components/navigation"
;
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
import
EventCalendar
from
"@/components/base/event-calendar"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
parse
from
"html-react-parser"
;
import
parse
from
"html-react-parser"
;
import
EventCalendar
from
"@/components/base/event-calendar"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
,
getDynamicPostBodyHtml
,
}
from
"./data"
;
import
type
{
DynamicCategoryRouteItem
,
DynamicPostItem
}
from
"./types"
;
export
default
function
ArticleDetailPage
({
data
}:
{
data
:
GetNewsResponseType
})
{
type
ArticleDetailPageProps
=
{
const
params
=
useParams
();
post
:
DynamicPostItem
;
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
category
:
DynamicCategoryRouteItem
|
null
;
allCategories
:
DynamicCategoryRouteItem
[];
};
//query
export
default
function
ArticleDetailPage
({
const
{
data
:
category
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
post
,
code
:
slug
[
0
],
category
,
});
allCategories
,
}:
ArticleDetailPageProps
)
{
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
const
children
=
category
?.
responseData
?.
children
??
[];
// template
return
(
return
(
<
div
className=
'container w-full flex justify-center items-center pb-10'
>
<
div
className=
"container w-full flex justify-center items-center pb-10"
>
<
div
className=
'flex flex-col gap-5 w-full'
>
<
div
className=
"flex flex-col gap-5 w-full"
>
{
children
.
length
!==
0
?
(
{
categoryMenu
.
length
>
0
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
<
br
/>
}
<
ListCategory
categories=
{
children
}
/>
)
:
(
<
br
/>
)
}
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-5"
>
<
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"
>
<
main
className=
"lg:col-span-2 bg-white border rounded-md p-8"
>
<
div
className=
'pb-5 text-primary text-2xl leading-normal font-medium'
>
<
div
className=
"pb-5 text-primary text-2xl leading-normal font-medium"
>
{
data
?.
responseData
?.
rows
[
0
]?
.
title
}
{
post
.
title
}
</
div
>
</
div
>
<
div
className=
'flex items-center gap-2 text-sm mb-4'
>
<
div
className=
"flex items-center gap-2 text-sm mb-4"
>
<
span
className=
'text-base text-blue-700'
>
<
span
className=
"text-base text-blue-700"
>
{
dayjs
(
data
?.
responseData
?.
rows
[
0
]?.
created_at
).
format
(
'DD/MM/YYYY'
)
}
{
dayjs
(
post
.
release_at
??
post
.
published_at
??
post
.
created_at
).
format
(
"DD/MM/YYYY"
)
}
</
span
>
</
span
>
</
div
>
</
div
>
<
hr
className=
"my-5"
/>
<
hr
className=
"my-5"
/>
<
div
className=
'flex-1 text-app-grey text-base overflow-hidden'
>
<
div
className=
"flex-1 text-app-grey text-base overflow-hidden"
>
<
div
className=
"prose tiptap overflow-hidden"
>
<
div
className=
"prose tiptap
max-w-none
overflow-hidden"
>
{
parse
(
data
?.
responseData
?.
rows
[
0
]?.
description
??
''
)
}
{
parse
(
getDynamicPostBodyHtml
(
post
)
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
main
>
</
main
>
...
...
src/app/(main)/[...slug]/templates/ArticlePage.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
GetNewsPageConfigResponseType
}
from
"@/api/types/news-page-config"
;
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
"@/api/endpoints/news-page-config"
;
import
{
usePathname
,
useRouter
,
useSearchParams
}
from
"next/navigation"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
useParams
,
useSearchParams
,
useRouter
,
usePathname
}
from
"next/navigation"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
{
useGetNews
}
from
"@/api/endpoints/news"
;
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
import
CardNews
from
"@/components/base/card-news"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
ListFilter
from
"@/components/base/list-filter"
;
import
ListFilter
from
"@/components/base/list-filter"
;
import
EventCalendar
from
"@/components/base/event-calendar"
;
import
EventCalendar
from
"@/components/base/event-calendar"
;
import
{
useState
,
useEffect
}
from
"react"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
Spinner
}
from
"@/components/ui"
;
import
{
buildDynamicCategoryMenu
,
buildPostFilters
,
fetchDynamicPostList
,
stripHtml
,
}
from
"./data"
;
import
type
{
DynamicCategoryRouteItem
}
from
"./types"
;
import
CardNews
from
"@/components/base/card-news"
;
export
default
function
ArticlePage
()
{
type
ArticlePageProps
=
{
// get url
category
:
DynamicCategoryRouteItem
;
const
params
=
useParams
();
allCategories
:
DynamicCategoryRouteItem
[];
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
};
const
path
=
slug
.
join
(
"/"
);
export
default
function
ArticlePage
({
category
,
allCategories
}:
ArticlePageProps
)
{
const
searchParams
=
useSearchParams
();
const
searchParams
=
useSearchParams
();
const
router
=
useRouter
();
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
searchParamsString
=
searchParams
.
toString
();
// states
const
initialPage
=
Number
(
searchParams
.
get
(
"page"
)
??
"1"
);
const
initialPage
=
Number
(
searchParams
.
get
(
"page"
)
??
"1"
);
const
[
submitSearch
,
setSubmitSearch
]
=
useState
(
""
);
const
[
submitSearch
,
setSubmitSearch
]
=
useState
(
""
);
const
[
page
,
setPage
]
=
useState
(
initialPage
);
const
[
page
,
setPage
]
=
useState
(
initialPage
);
const
pageSize
=
5
;
const
pageSize
=
6
;
const
keyword
=
submitSearch
.
trim
();
useEffect
(()
=>
{
useEffect
(()
=>
{
const
params
=
new
URLSearchParams
(
searchParams
.
toString
()
);
const
params
=
new
URLSearchParams
(
searchParams
String
);
if
(
page
>
1
)
{
if
(
page
>
1
)
{
params
.
set
(
"page"
,
String
(
page
));
params
.
set
(
"page"
,
String
(
page
));
}
else
{
}
else
{
params
.
delete
(
"page"
);
params
.
delete
(
"page"
);
}
}
const
qs
=
params
.
toString
();
const
qs
=
params
.
toString
();
router
.
replace
(
qs
?
`
${
pathname
}
?
${
qs
}
`
:
pathname
,
{
scroll
:
false
})
;
const
nextUrl
=
qs
?
`
${
pathname
}
?
${
qs
}
`
:
pathname
;
},
[
page
])
;
const
currentUrl
=
searchParamsString
?
`
${
pathname
}
?
${
searchParamsString
}
`
:
pathname
;
// query
if
(
nextUrl
!==
currentUrl
)
{
const
{
data
:
category
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
router
.
replace
(
nextUrl
,
{
scroll
:
false
});
code
:
slug
[
0
],
}
});
},
[
page
,
pathname
,
router
,
searchParamsString
]);
useEffect
(()
=>
{
setPage
(
1
);
},
[
submitSearch
,
category
.
id
]);
const
{
data
:
articles
,
isLoading
:
articlesLoading
}
=
useGetNews
<
GetNewsResponseType
>
({
const
postsQuery
=
useQuery
({
filters
:
`page_config.static_link==/
${
path
}
`
+
(
submitSearch
?
`,title@=
${
submitSearch
}
`
:
""
),
queryKey
:
[
"dynamic-posts"
,
category
.
id
,
page
,
pageSize
,
keyword
],
pageSize
:
String
(
pageSize
),
queryFn
:
()
=>
currentPage
:
String
(
page
),
fetchDynamicPostList
({
page
,
pageSize
,
filters
:
buildPostFilters
([
`category.id==
${
category
.
id
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"status==published"
,
"type==news"
,
keyword
?
`title@=
${
keyword
}
`
:
null
,
]),
}),
staleTime
:
60
*
1000
,
});
});
const
children
=
category
?.
responseData
?.
children
??
[];
const
categoryMenu
=
useMemo
(
//template
()
=>
buildDynamicCategoryMenu
(
category
,
allCategories
),
[
category
,
allCategories
],
);
const
totalPages
=
postsQuery
.
data
?.
totalPages
??
1
;
const
currentPage
=
Math
.
min
(
page
,
totalPages
);
const
paginatedPosts
=
postsQuery
.
data
?.
rows
??
[];
return
(
return
(
<
div
className=
"min-h-screen container mx-auto"
>
<
div
className=
"min-h-screen container mx-auto"
>
{
article
sLoading
?
(
{
postsQuery
.
i
sLoading
?
(
<
div
className=
"flex justify-center items-center w-full h-64"
>
<
div
className=
"flex justify-center items-center w-full h-64"
>
<
Spinner
/>
<
Spinner
/>
</
div
>
</
div
>
)
:
(
)
:
(
<
div
className=
"w-full flex flex-col gap-5"
>
<
div
className=
"w-full flex flex-col gap-5"
>
{
children
.
length
!==
0
?
(
{
categoryMenu
.
length
>
0
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
<
br
/>
}
<
ListCategory
categories=
{
children
}
/>
)
:
(
<
br
/>
)
}
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
main
className=
"lg:col-span-2 bg-background"
>
<
main
className=
"lg:col-span-2 bg-background"
>
<
div
className=
"pb-5 overflow-hidden"
>
<
div
className=
"pb-5 overflow-hidden"
>
{
articles
?.
responseData
?.
rows
.
map
((
item
)
=>
(
{
paginatedPosts
.
length
?
(
paginatedPosts
.
map
((
item
)
=>
{
const
fallbackDescription
=
item
.
content_structure
?.
post_content
?.
map
((
section
)
=>
section
.
content
)
.
join
(
" "
);
return
(
<
CardNews
<
CardNews
key=
{
item
.
id
}
key=
{
item
.
id
}
news=
{
item
}
news=
{
{
link=
{
`${item.external_link}`
}
id
:
item
.
id
,
title
:
item
.
title
,
thumbnail
:
item
.
thumbnail
?.
path
??
item
.
thumbnail
?.
original
??
item
.
thumbnail
?.
url
??
""
,
external_link
:
item
.
external_link
,
description
:
item
.
summary
||
stripHtml
(
item
.
content
)
||
stripHtml
(
fallbackDescription
),
release_at
:
item
.
release_at
??
item
.
published_at
??
item
.
created_at
??
""
,
is_active
:
item
.
is_active
,
created_at
:
item
.
created_at
??
""
,
created_by
:
null
,
updated_at
:
item
.
created_at
??
""
,
updated_by
:
null
,
mode
:
"NOW"
,
category
:
category
.
name
,
page_config
:
{
id
:
category
.
id
,
name
:
category
.
name
,
static_link
:
category
.
url
,
static_link_en
:
category
.
url
,
code
:
category
.
slug
,
},
}
}
link=
{
item
.
external_link
}
/>
/>
))
}
);
})
)
:
(
<
div
className=
"rounded-lg border 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."
}
</
div
>
)
}
<
div
className=
"w-full flex justify-center mt-4"
>
<
div
className=
"w-full flex justify-center mt-4"
>
<
Pagination
<
Pagination
pageCount=
{
Number
(
articles
?.
responseData
?.
totalPages
??
1
)
}
pageCount=
{
totalPages
}
page=
{
Number
(
articles
?.
responseData
?.
currentPage
??
page
)
}
page=
{
currentPage
}
onChangePage=
{
setPage
}
onChangePage=
{
setPage
}
onGoToPreviousPage=
{
()
=>
setPage
(
Math
.
max
(
1
,
page
-
1
))
}
onGoToPreviousPage=
{
()
=>
setPage
(
Math
.
max
(
1
,
currentPage
-
1
))
}
onGoToNextPage=
{
()
=>
onGoToNextPage=
{
()
=>
setPage
(
Math
.
min
(
totalPages
,
currentPage
+
1
))
}
setPage
(
Math
.
min
(
Number
(
articles
?.
responseData
?.
totalPages
??
1
),
page
+
1
))
}
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
main
>
</
main
>
<
aside
className=
"space-y-6"
>
<
aside
className=
"space-y-6"
>
<
ListFilter
onSearch=
{
setSubmitSearch
}
/>
<
ListFilter
onSearch=
{
setSubmitSearch
}
onReset=
{
()
=>
setSubmitSearch
(
""
)
}
/>
<
EventCalendar
/>
<
EventCalendar
/>
<
div
className=
"bg-white border rounded-md overflow-hidden"
>
<
div
className=
"bg-white border rounded-md overflow-hidden"
>
<
div
className=
"w-full relative bg-gray-100"
>
<
div
className=
"w-full relative bg-gray-100"
>
<
img
src=
"/banner.webp"
alt=
"Quảng cáo"
className=
"object-cover"
/>
<
img
src=
"/banner.webp"
alt=
{
"Qu
\
u1ea3ng c
\
u00e1o"
}
className=
"object-cover"
/>
</
div
>
</
div
>
</
div
>
</
div
>
</
aside
>
</
aside
>
...
...
src/app/(main)/[...slug]/templates/EventPage.tsx
View file @
82063bf1
...
@@ -23,6 +23,7 @@ export default function EventPage() {
...
@@ -23,6 +23,7 @@ export default function EventPage() {
const
searchParams
=
useSearchParams
();
const
searchParams
=
useSearchParams
();
const
router
=
useRouter
();
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
searchParamsString
=
searchParams
.
toString
();
// states
// states
const
initialPage
=
Number
(
searchParams
.
get
(
"page"
)
??
"1"
);
const
initialPage
=
Number
(
searchParams
.
get
(
"page"
)
??
"1"
);
...
@@ -31,15 +32,20 @@ export default function EventPage() {
...
@@ -31,15 +32,20 @@ export default function EventPage() {
const
pageSize
=
5
;
const
pageSize
=
5
;
useEffect
(()
=>
{
useEffect
(()
=>
{
const
params
=
new
URLSearchParams
(
searchParams
.
toString
()
);
const
params
=
new
URLSearchParams
(
searchParams
String
);
if
(
page
>
1
)
{
if
(
page
>
1
)
{
params
.
set
(
"page"
,
String
(
page
));
params
.
set
(
"page"
,
String
(
page
));
}
else
{
}
else
{
params
.
delete
(
"page"
);
params
.
delete
(
"page"
);
}
}
const
qs
=
params
.
toString
();
const
qs
=
params
.
toString
();
router
.
replace
(
qs
?
`
${
pathname
}
?
${
qs
}
`
:
pathname
,
{
scroll
:
false
});
const
nextUrl
=
qs
?
`
${
pathname
}
?
${
qs
}
`
:
pathname
;
},
[
page
]);
const
currentUrl
=
searchParamsString
?
`
${
pathname
}
?
${
searchParamsString
}
`
:
pathname
;
if
(
nextUrl
!==
currentUrl
)
{
router
.
replace
(
nextUrl
,
{
scroll
:
false
});
}
},
[
page
,
pathname
,
router
,
searchParamsString
]);
// query
// query
const
{
data
:
categoriesPage
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
const
{
data
:
categoriesPage
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
...
...
src/app/(main)/[...slug]/templates/InformationPage.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
{
GetNewsPageConfigResponseType
}
from
"@/api/types/news-page-config"
;
import
{
useGetNewsPageConfigGetHierarchical
}
from
"@/api/endpoints/news-page-config"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
useParams
}
from
"next/dist/client/components/navigation"
;
import
{
Spinner
}
from
"@/components/ui/spinner"
;
import
{
GetNewsResponseType
}
from
"@/api/types/news"
;
import
{
useGetNews
}
from
"@/api/endpoints/news"
;
import
parse
from
"html-react-parser"
;
import
parse
from
"html-react-parser"
;
import
ListCategory
from
"@/components/base/list-category"
;
import
{
buildDynamicCategoryMenu
,
getDynamicPostBodyHtml
,
}
from
"./data"
;
import
type
{
DynamicCategoryRouteItem
,
DynamicPostItem
}
from
"./types"
;
export
default
function
InformationPage
()
{
type
InformationPageProps
=
{
// get url
post
:
DynamicPostItem
;
const
params
=
useParams
();
category
:
DynamicCategoryRouteItem
;
const
slug
=
Array
.
isArray
(
params
.
slug
)
?
params
.
slug
:
[
params
.
slug
];
allCategories
:
DynamicCategoryRouteItem
[];
const
path
=
slug
.
join
(
"/"
);
};
// query
const
{
data
:
category
}
=
useGetNewsPageConfigGetHierarchical
<
GetNewsPageConfigResponseType
>
({
static_link
:
`/
${
slug
[
0
]}
`
,
});
const
{
data
:
information
,
isLoading
:
informationLoading
}
=
useGetNews
<
GetNewsResponseType
>
({
export
default
function
InformationPage
({
filters
:
`page_config.static_link==/
${
path
}
`
,
post
,
});
category
,
allCategories
,
}:
InformationPageProps
)
{
const
categoryMenu
=
buildDynamicCategoryMenu
(
category
,
allCategories
);
const
children
=
category
?.
responseData
?.
children
??
[];
//template
return
(
return
(
<
div
className=
'container w-full flex justify-center items-center pb-10'
>
<
div
className=
"container w-full flex justify-center items-center pb-10"
>
{
informationLoading
?
(
<
div
className=
"flex flex-col gap-5 w-full"
>
<
div
className=
"flex justify-center items-center w-full h-64"
>
{
categoryMenu
.
length
>
0
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
<
br
/>
}
<
Spinner
/>
<
main
className=
"bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20"
>
</
div
>
<
div
className=
"text-primary text-2xl leading-normal font-bold"
>
)
:
(
{
post
.
title
}
<
div
className=
'flex flex-col gap-5 w-full'
>
{
children
.
length
!==
0
?
(
<
ListCategory
categories=
{
children
}
/>
)
:
(
<
br
/>
)
}
<
main
className=
" bg-white border rounded-md py-10 px-5 md:px-20 lg:px-20"
>
<
div
className=
'text-primary text-2xl leading-normal font-bold'
>
{
information
?.
responseData
?.
rows
[
0
]?.
title
}
</
div
>
</
div
>
{
/* <div className='flex items-center gap-2 text-sm mb-4'>
<span className='text-base text-blue-700'>
{dayjs(information?.responseData?.rows[0].created_at).format('DD/MM/YYYY')}
</span>
</div> */
}
<
hr
className=
"my-5"
/>
<
hr
className=
"my-5"
/>
<
div
className=
'flex-1 text-app-grey text-base overflow-hidden'
>
<
div
className=
"flex-1 text-app-grey text-base overflow-hidden"
>
<
div
className=
"prose tiptap
overflow-hidden"
>
<
div
className=
"prose tiptap max-w-none
overflow-hidden"
>
{
parse
(
information
?.
responseData
?.
rows
[
0
]?.
description
??
''
)
}
{
parse
(
getDynamicPostBodyHtml
(
post
)
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
main
>
</
main
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
);
);
}
}
src/app/(main)/[...slug]/templates/data.ts
0 → 100644
View file @
82063bf1
import
type
{
Category
}
from
"@/api/models/category"
;
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
Links
from
"@/links"
;
import
{
getCategoryFallbackResponse
}
from
"@/mockdata/categories"
;
import
type
{
DynamicCategoryMenuItem
,
DynamicCategoryRouteItem
,
DynamicCategoryType
,
DynamicPostContentSection
,
DynamicPostItem
,
DynamicPostThumbnail
,
}
from
"./types"
;
type
CategoryListResponse
=
{
responseData
?:
{
rows
?:
Category
[];
};
};
type
RawPostCategory
=
{
id
?:
string
|
null
;
name
?:
string
|
null
;
url
?:
string
|
null
;
type
?:
string
|
null
;
};
type
RawPostThumbnail
=
{
path
?:
string
|
null
;
original
?:
string
|
null
;
url
?:
string
|
null
;
};
type
RawPostItem
=
{
id
?:
string
|
null
;
title
?:
string
|
null
;
slug
?:
string
|
null
;
external_link
?:
string
|
null
;
content
?:
string
|
null
;
summary
?:
string
|
null
;
release_at
?:
string
|
null
;
published_at
?:
string
|
null
;
created_at
?:
string
|
null
;
started_at
?:
string
|
null
;
ended_at
?:
string
|
null
;
expired_at
?:
string
|
null
;
registration_deadline
?:
string
|
null
;
is_featured
?:
boolean
|
null
;
is_hidden
?:
boolean
|
null
;
is_active
?:
boolean
|
null
;
status
?:
string
|
null
;
type
?:
string
|
null
;
thumbnail
?:
RawPostThumbnail
|
null
;
categories
?:
RawPostCategory
[]
|
null
;
content_structure
?:
{
post_content
?:
Array
<
{
id
?:
string
|
null
;
type
?:
string
|
null
;
content
?:
string
|
null
;
position
?:
number
|
null
;
}
>
|
null
;
}
|
null
;
};
type
PostListResponse
=
{
responseData
?:
{
count
?:
number
;
page
?:
number
;
pageSize
?:
number
;
rows
?:
RawPostItem
[];
};
};
export
type
DynamicPostListResult
=
{
count
:
number
;
page
:
number
;
pageSize
:
number
;
totalPages
:
number
;
rows
:
DynamicPostItem
[];
};
const
normalizePath
=
(
value
?:
string
|
null
)
=>
{
const
trimmed
=
value
?.
trim
()
??
""
;
if
(
!
trimmed
||
trimmed
===
"/"
)
return
"/"
;
return
`/
${
trimmed
.
replace
(
/^
\/
+|
\/
+$/g
,
""
)}
`
;
};
const
normalizeCategoryType
=
(
value
?:
string
|
null
):
DynamicCategoryType
|
null
=>
{
if
(
value
===
"category"
||
value
===
"page"
||
value
===
"news"
)
return
value
;
return
null
;
};
const
sortCategories
=
(
items
:
DynamicCategoryRouteItem
[])
=>
[...
items
].
sort
((
left
,
right
)
=>
{
const
leftOrder
=
left
.
sort_order
??
Number
.
MAX_SAFE_INTEGER
;
const
rightOrder
=
right
.
sort_order
??
Number
.
MAX_SAFE_INTEGER
;
if
(
leftOrder
!==
rightOrder
)
return
leftOrder
-
rightOrder
;
return
left
.
name
.
localeCompare
(
right
.
name
,
"vi"
);
});
const
mapPostContentSections
=
(
item
:
RawPostItem
):
DynamicPostContentSection
[]
=>
{
const
sections
=
Array
.
isArray
(
item
.
content_structure
?.
post_content
)
?
item
.
content_structure
.
post_content
:
[];
return
sections
.
map
((
section
,
index
)
=>
({
id
:
String
(
section
?.
id
??
`section-
${
index
+
1
}
`
),
type
:
String
(
section
?.
type
??
"text"
),
content
:
String
(
section
?.
content
??
""
),
position
:
typeof
section
?.
position
===
"number"
?
section
.
position
:
index
+
1
,
}));
};
const
mapPost
=
(
item
:
RawPostItem
):
DynamicPostItem
=>
({
id
:
String
(
item
.
id
??
""
),
title
:
String
(
item
.
title
??
""
).
trim
(),
slug
:
String
(
item
.
slug
??
""
).
trim
(),
external_link
:
normalizePath
(
item
.
external_link
),
content
:
String
(
item
.
content
??
""
),
summary
:
String
(
item
.
summary
??
""
),
release_at
:
item
.
release_at
??
null
,
published_at
:
item
.
published_at
??
null
,
created_at
:
item
.
created_at
??
null
,
started_at
:
item
.
started_at
??
null
,
ended_at
:
item
.
ended_at
??
null
,
expired_at
:
item
.
expired_at
??
null
,
registration_deadline
:
item
.
registration_deadline
??
null
,
is_featured
:
Boolean
(
item
.
is_featured
),
is_hidden
:
Boolean
(
item
.
is_hidden
),
is_active
:
item
.
is_active
!==
false
,
status
:
String
(
item
.
status
??
""
),
type
:
String
(
item
.
type
??
""
),
thumbnail
:
(
item
.
thumbnail
??
null
)
as
DynamicPostThumbnail
,
categories
:
(
item
.
categories
??
[])
.
filter
((
category
)
=>
category
?.
id
&&
category
?.
name
)
.
map
((
category
)
=>
({
id
:
String
(
category
.
id
),
name
:
String
(
category
.
name
),
url
:
normalizePath
(
category
.
url
),
type
:
String
(
category
.
type
??
""
),
})),
content_structure
:
{
post_content
:
mapPostContentSections
(
item
),
},
});
const
buildPostFilters
=
(
filters
:
Array
<
string
|
null
|
undefined
>
)
=>
filters
.
map
((
item
)
=>
item
?.
trim
())
.
filter
(
Boolean
)
.
join
(
","
);
export
async
function
fetchDynamicCategories
():
Promise
<
DynamicCategoryRouteItem
[]
>
{
const
response
=
await
useCustomClient
<
CategoryListResponse
>
(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC"
,
).
catch
(()
=>
getCategoryFallbackResponse
());
const
rows
=
response
.
responseData
?.
rows
??
[];
return
sortCategories
(
rows
.
map
((
item
)
=>
{
const
type
=
normalizeCategoryType
(
item
.
type
);
if
(
!
item
.
id
||
!
item
.
name
||
!
type
)
return
null
;
return
{
id
:
item
.
id
,
name
:
item
.
name
,
slug
:
item
.
slug
??
""
,
url
:
normalizePath
(
item
.
url
),
type
,
parent_id
:
item
.
parent_id
??
null
,
sort_order
:
item
.
sort_order
??
null
,
}
satisfies
DynamicCategoryRouteItem
;
})
.
filter
((
item
):
item
is
DynamicCategoryRouteItem
=>
Boolean
(
item
)),
);
}
export
async
function
fetchDynamicPostList
(
params
:
{
filters
?:
string
;
page
?:
number
;
pageSize
?:
number
;
sortField
?:
string
;
sortOrder
?:
string
;
}):
Promise
<
DynamicPostListResult
>
{
const
page
=
params
.
page
??
1
;
const
pageSize
=
params
.
pageSize
??
5
;
const
query
=
new
URLSearchParams
({
page
:
String
(
page
),
pageSize
:
String
(
pageSize
),
sortField
:
params
.
sortField
??
"release_at"
,
sortOrder
:
params
.
sortOrder
??
"desc"
,
});
if
(
params
.
filters
?.
trim
())
{
query
.
set
(
"filters"
,
params
.
filters
.
trim
());
}
const
response
=
await
useCustomClient
<
PostListResponse
>
(
`/post?
${
query
.
toString
()}
`
);
const
count
=
Number
(
response
.
responseData
?.
count
??
0
);
return
{
count
,
page
,
pageSize
,
totalPages
:
pageSize
>
0
?
Math
.
max
(
1
,
Math
.
ceil
(
count
/
pageSize
))
:
1
,
rows
:
(
response
.
responseData
?.
rows
??
[]).
map
(
mapPost
).
filter
((
item
)
=>
item
.
id
&&
item
.
title
),
};
}
export
async
function
fetchDynamicPostByExternalLink
(
path
:
string
)
{
const
result
=
await
fetchDynamicPostList
({
page
:
1
,
pageSize
:
1
,
filters
:
buildPostFilters
([
`external_link==
${
normalizePath
(
path
)}
`
,
"is_hidden==false"
,
"is_active==true"
,
"status==published"
,
]),
});
return
result
.
rows
[
0
]
??
null
;
}
export
async
function
fetchDynamicSinglePagePost
(
categoryId
:
string
)
{
const
result
=
await
fetchDynamicPostList
({
page
:
1
,
pageSize
:
1
,
filters
:
buildPostFilters
([
`category.id==
${
categoryId
}
`
,
"is_hidden==false"
,
"is_active==true"
,
"type==page"
,
]),
});
return
result
.
rows
[
0
]
??
null
;
}
export
function
findDynamicCategoryByPath
(
categories
:
DynamicCategoryRouteItem
[],
path
:
string
,
)
{
const
normalizedPath
=
normalizePath
(
path
);
return
categories
.
find
((
item
)
=>
normalizePath
(
item
.
url
)
===
normalizedPath
)
??
null
;
}
export
function
findMenuCategoryForPost
(
post
:
DynamicPostItem
|
null
,
categories
:
DynamicCategoryRouteItem
[],
)
{
if
(
!
post
)
return
null
;
for
(
const
category
of
post
.
categories
)
{
const
matched
=
categories
.
find
((
item
)
=>
item
.
id
===
category
.
id
);
if
(
matched
)
return
matched
;
}
return
null
;
}
export
function
buildDynamicCategoryMenu
(
activeCategory
:
DynamicCategoryRouteItem
|
null
,
categories
:
DynamicCategoryRouteItem
[],
):
DynamicCategoryMenuItem
[]
{
if
(
!
activeCategory
)
return
[];
const
relatedItems
=
activeCategory
.
parent_id
?
categories
.
filter
((
item
)
=>
item
.
parent_id
===
activeCategory
.
parent_id
)
:
categories
.
filter
((
item
)
=>
item
.
parent_id
===
activeCategory
.
id
);
return
sortCategories
(
relatedItems
).
map
((
item
)
=>
({
id
:
item
.
id
,
name
:
item
.
name
,
static_link
:
item
.
url
,
}));
}
export
function
findFirstChildCategory
(
category
:
DynamicCategoryRouteItem
,
categories
:
DynamicCategoryRouteItem
[],
)
{
return
sortCategories
(
categories
.
filter
((
item
)
=>
item
.
parent_id
===
category
.
id
))[
0
]
??
null
;
}
export
function
resolveDynamicPostImage
(
thumbnail
?:
DynamicPostThumbnail
)
{
const
value
=
thumbnail
?.
path
??
thumbnail
?.
original
??
thumbnail
?.
url
??
""
;
if
(
!
value
)
return
"/thumbnail.png"
;
if
(
value
.
startsWith
(
"http://"
)
||
value
.
startsWith
(
"https://"
))
return
value
;
if
(
value
.
startsWith
(
"/"
))
return
`
${
Links
.
imageEndpoint
.
replace
(
/
\/
+$/
,
""
)}${
value
}
`
;
return
`
${
Links
.
imageEndpoint
}${
value
.
replace
(
/^
\/
+/
,
""
)}
`
;
}
export
function
stripHtml
(
value
?:
string
|
null
)
{
if
(
!
value
)
return
""
;
return
value
.
replace
(
/<img
[^
>
]
*>/gi
,
" "
)
.
replace
(
/<
[^
>
]
+>/g
,
" "
)
.
replace
(
/
\s
+/g
,
" "
)
.
trim
();
}
export
function
getDynamicPostBodyHtml
(
post
:
DynamicPostItem
|
null
)
{
if
(
!
post
)
return
""
;
const
primaryContent
=
post
.
content
?.
trim
();
if
(
primaryContent
)
return
primaryContent
;
const
structuredContent
=
(
post
.
content_structure
?.
post_content
??
[])
.
sort
((
left
,
right
)
=>
left
.
position
-
right
.
position
)
.
map
((
section
)
=>
section
.
content
?.
trim
()
??
""
)
.
filter
(
Boolean
)
.
join
(
"
\n
"
);
return
structuredContent
||
post
.
summary
?.
trim
()
||
""
;
}
export
function
matchesDynamicPostCategory
(
post
:
DynamicPostItem
,
categoryId
:
string
)
{
return
post
.
categories
.
some
((
category
)
=>
category
.
id
===
categoryId
);
}
export
function
isDynamicPostVisible
(
post
:
DynamicPostItem
)
{
if
(
post
.
is_hidden
)
return
false
;
if
(
!
post
.
is_active
)
return
false
;
if
(
post
.
status
&&
post
.
status
!==
"published"
)
return
false
;
return
true
;
}
export
{
buildPostFilters
,
normalizePath
};
src/app/(main)/[...slug]/templates/types.ts
0 → 100644
View file @
82063bf1
export
type
DynamicCategoryType
=
"category"
|
"page"
|
"news"
;
export
type
DynamicCategoryRouteItem
=
{
id
:
string
;
name
:
string
;
slug
:
string
;
url
:
string
;
type
:
DynamicCategoryType
;
parent_id
:
string
|
null
;
sort_order
:
number
|
null
;
};
export
type
DynamicCategoryMenuItem
=
{
id
:
string
;
name
:
string
;
static_link
:
string
;
};
export
type
DynamicPostCategoryItem
=
{
id
:
string
;
name
:
string
;
url
:
string
;
type
:
string
;
};
export
type
DynamicPostThumbnail
=
{
path
?:
string
|
null
;
original
?:
string
|
null
;
url
?:
string
|
null
;
}
|
null
;
export
type
DynamicPostContentSection
=
{
id
:
string
;
type
:
string
;
content
:
string
;
position
:
number
;
};
export
type
DynamicPostItem
=
{
id
:
string
;
title
:
string
;
slug
:
string
;
external_link
:
string
;
content
:
string
;
summary
:
string
;
release_at
:
string
|
null
;
published_at
:
string
|
null
;
created_at
:
string
|
null
;
started_at
:
string
|
null
;
ended_at
:
string
|
null
;
expired_at
:
string
|
null
;
registration_deadline
:
string
|
null
;
is_featured
:
boolean
;
is_hidden
:
boolean
;
is_active
:
boolean
;
status
:
string
;
type
:
string
;
thumbnail
:
DynamicPostThumbnail
;
categories
:
DynamicPostCategoryItem
[];
content_structure
:
{
post_content
:
DynamicPostContentSection
[];
}
|
null
;
};
src/app/admin/base-config/page.tsx
View file @
82063bf1
...
@@ -432,7 +432,11 @@ export default function AdminBaseConfigPage() {
...
@@ -432,7 +432,11 @@ export default function AdminBaseConfigPage() {
saveConfig
(
nextConfig
);
saveConfig
(
nextConfig
);
setSavingItem
(
false
);
setSavingItem
(
false
);
setItemDialogOpen
(
false
);
setItemDialogOpen
(
false
);
toast
.
success
(
itemDialogMode
===
"logo"
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
);
toast
.
success
(
itemDialogMode
===
"logo"
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
,
);
};
};
const
handleDeleteItem
=
()
=>
{
const
handleDeleteItem
=
()
=>
{
...
@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() {
...
@@ -575,7 +579,7 @@ export default function AdminBaseConfigPage() {
value=
"social"
value=
"social"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
>
M
ạng xã hộ
i
M
?ng x? h?
i
</
TabsTrigger
>
</
TabsTrigger
>
</
TabsList
>
</
TabsList
>
...
@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() {
...
@@ -700,7 +704,7 @@ export default function AdminBaseConfigPage() {
</
div
>
</
div
>
)
:
(
)
:
(
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500"
>
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500"
>
Ch
ưa có logo nào. Hãy thiết lậ
p logo cho website.
Ch
ua c? logo n?o. H?y thi?t l?
p logo cho website.
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -974,7 +978,7 @@ export default function AdminBaseConfigPage() {
...
@@ -974,7 +978,7 @@ export default function AdminBaseConfigPage() {
</>
</>
)
:
(
)
:
(
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500"
>
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500"
>
Ch
ưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hì
nh.
Ch
ua c? chi nh?nh n?o. H?y th?m chi nh?nh d? b?t d?u c?u h?
nh.
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1018,9 +1022,9 @@ export default function AdminBaseConfigPage() {
<
CardHeader
>
<
CardHeader
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
div
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
M
ạng xã hộ
i
</
CardTitle
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
M
?ng x? h?
i
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Qu
ản lý link mạng xã hội và thứ tự hiển thị trê
n website.
Qu
?n l? link m?ng x? h?i v? th? t? hi?n th? tr?
n website.
</
CardDescription
>
</
CardDescription
>
</
div
>
</
div
>
...
@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() {
...
@@ -1030,7 +1034,7 @@ export default function AdminBaseConfigPage() {
className=
"rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
className=
"rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
>
<
Save
className=
"mr-2 h-4 w-4"
/>
<
Save
className=
"mr-2 h-4 w-4"
/>
L
ưu mạng xã hộ
i
L
uu m?ng x? h?
i
</
Button
>
</
Button
>
</
div
>
</
div
>
</
CardHeader
>
</
CardHeader
>
...
...
src/app/admin/contact-management/contact-requests/page.tsx
View file @
82063bf1
...
@@ -2,13 +2,13 @@
...
@@ -2,13 +2,13 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Trash2
}
from
"lucide-react"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
Select
,
Select
,
SelectContent
,
SelectContent
,
...
@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() {
...
@@ -177,26 +177,12 @@ export default function AdminContactRequestsPage() {
{
formatDateTime
(
item
.
submittedAt
)
}
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"view"
,
label
:
"Xem chi tiết đơn"
,
onClick
:
()
=>
setDetailTarget
(
item
)
},
variant=
"ghost"
{
kind
:
"delete"
,
label
:
"Xóa đơn liên hệ"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
size=
"icon"
]
}
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
/>
onClick=
{
()
=>
setDetailTarget
(
item
)
}
>
<
Eye
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/contact-management/membership-applications/page.tsx
View file @
82063bf1
...
@@ -2,13 +2,13 @@
...
@@ -2,13 +2,13 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Trash2
}
from
"lucide-react"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -136,26 +136,16 @@ export default function AdminMembershipApplicationsPage() {
{
formatDateTime
(
item
.
submittedAt
)
}
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"view"
,
label
:
"Xem chi tiết đơn"
,
onClick
:
()
=>
setDetailTarget
(
item
)
},
variant=
"ghost"
{
size=
"icon"
kind
:
"delete"
,
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
label
:
"Xóa đơn đăng ký hội viên"
,
onClick=
{
()
=>
setDetailTarget
(
item
)
}
onClick
:
()
=>
setDeleteTarget
(
item
),
>
},
<
Eye
className=
"h-4 w-4"
/>
]
}
</
Button
>
/>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/contact-management/newsletter-emails/page.tsx
View file @
82063bf1
...
@@ -2,12 +2,12 @@
...
@@ -2,12 +2,12 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Mail
,
Trash2
}
from
"lucide-react"
;
import
{
Mail
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -117,26 +117,12 @@ export default function AdminNewsletterEmailsPage() {
...
@@ -117,26 +117,12 @@ export default function AdminNewsletterEmailsPage() {
{
formatDateTime
(
item
.
submittedAt
)
}
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"view"
,
label
:
"Xem chi tiết email"
,
onClick
:
()
=>
setDetailTarget
(
item
)
},
variant=
"ghost"
{
kind
:
"delete"
,
label
:
"Xóa email đăng ký"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
size=
"icon"
]
}
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
/>
onClick=
{
()
=>
setDetailTarget
(
item
)
}
>
<
Eye
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/dashboard/page.tsx
View file @
82063bf1
...
@@ -159,7 +159,7 @@ export default function AdminDashboardPage() {
...
@@ -159,7 +159,7 @@ export default function AdminDashboardPage() {
()
=>
[
()
=>
[
{
{
title
:
"Cấu hình chung"
,
title
:
"Cấu hình chung"
,
description
:
"Logo, banner, chi nh
ánh liên hệ và mạng xã hộ
i"
,
description
:
"Logo, banner, chi nh
?nh li?n h? v? m?ng x? h?
i"
,
href
:
"/admin/base-config"
,
href
:
"/admin/base-config"
,
icon
:
Globe
,
icon
:
Globe
,
},
},
...
@@ -313,7 +313,7 @@ export default function AdminDashboardPage() {
...
@@ -313,7 +313,7 @@ export default function AdminDashboardPage() {
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
activeBanners
.
length
}
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
activeBanners
.
length
}
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
M
ạng xã hội hiển thị
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
M
?ng x? h?i hi?n th?
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
visibleSocials
.
length
}
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
visibleSocials
.
length
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
src/app/admin/header-config/[categoryId]/posts/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
React
from
"react"
;
import
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
...
@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation";
...
@@ -7,27 +7,18 @@ import { useParams, useRouter } from "next/navigation";
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
import
{
ArrowLeft
,
ArrowLeft
,
Edit
,
EyeOff
,
EyeOff
,
FileText
,
FileText
,
MoreHorizontal
,
Plus
,
Plus
,
Star
,
Star
,
Trash2
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuSeparator
,
DropdownMenuTrigger
,
}
from
"@/components/ui/dropdown-menu"
;
import
{
import
{
Table
,
Table
,
TableBody
,
TableBody
,
...
@@ -359,35 +350,20 @@ export default function HeaderCategoryPostsPage() {
...
@@ -359,35 +350,20 @@ export default function HeaderCategoryPostsPage() {
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"text-center"
>
<
DropdownMenu
>
<
AdminRowActions
<
DropdownMenuTrigger
asChild
>
actions=
{
[
<
Button
{
variant=
"ghost"
kind
:
"edit"
,
className=
"h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
label
:
"Chỉnh sửa bài viết"
,
>
onClick
:
()
=>
router
.
push
(
`/admin/header-config/${categoryId}/posts/${item.id}`
),
<
MoreHorizontal
className=
"h-4 w-4"
/>
},
</
Button
>
{
</
DropdownMenuTrigger
>
kind
:
"delete"
,
<
DropdownMenuContent
align=
"end"
>
label
:
"Xóa bài viết"
,
<
DropdownMenuItem
onClick
:
()
=>
setDeleteTarget
(
item
),
asChild
},
className=
"text-gray-700 focus:text-[#063e8e]"
]
}
>
/>
<
Link
href=
{
`/admin/header-config/${categoryId}/posts/${item.id}`
}
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Chỉnh sửa
</
Link
>
</
DropdownMenuItem
>
<
DropdownMenuSeparator
/>
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"mr-2 h-4 w-4"
/>
Xóa
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/header-config/components/header-category-table.tsx
View file @
82063bf1
...
@@ -5,24 +5,14 @@ import Link from "next/link";
...
@@ -5,24 +5,14 @@ import Link from "next/link";
import
{
import
{
ChevronDown
,
ChevronDown
,
ChevronRight
,
ChevronRight
,
Edit
,
ExternalLink
,
ExternalLink
,
FileText
,
FileText
,
FolderTree
,
FolderTree
,
MoreHorizontal
,
Plus
,
Plus
,
Trash
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuSeparator
,
DropdownMenuTrigger
,
}
from
"@/components/ui/dropdown-menu"
;
import
{
Skeleton
}
from
"@/components/ui/skeleton"
;
import
{
Skeleton
}
from
"@/components/ui/skeleton"
;
import
{
import
{
Table
,
Table
,
...
@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
...
@@ -42,6 +32,8 @@ export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
parentId
:
string
|
null
;
parentId
:
string
|
null
;
};
};
const
PROTECTED_HOME_CATEGORY_ID
=
"root-home"
;
interface
HeaderCategoryTableProps
{
interface
HeaderCategoryTableProps
{
rows
:
HeaderCategoryFlatRow
[];
rows
:
HeaderCategoryFlatRow
[];
expanded
:
Record
<
string
,
boolean
>
;
expanded
:
Record
<
string
,
boolean
>
;
...
@@ -160,8 +152,11 @@ export function HeaderCategoryTable({
...
@@ -160,8 +152,11 @@ export function HeaderCategoryTable({
rows
.
map
((
item
,
index
)
=>
{
rows
.
map
((
item
,
index
)
=>
{
const
hasChildren
=
item
.
children
.
length
>
0
;
const
hasChildren
=
item
.
children
.
length
>
0
;
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
isExpanded
=
expanded
[
item
.
id
]
??
true
;
const
canCreateChild
=
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
isProtectedHomeCategory
=
item
.
id
===
PROTECTED_HOME_CATEGORY_ID
;
const
canManagePosts
=
item
.
type
===
"page"
||
item
.
type
===
"news"
;
const
canCreateChild
=
!
isProtectedHomeCategory
&&
!
item
.
parent_id
&&
item
.
type
===
"category"
;
const
canManagePosts
=
!
isProtectedHomeCategory
&&
(
item
.
type
===
"page"
||
item
.
type
===
"news"
);
return
(
return
(
<
TableRow
<
TableRow
...
@@ -221,56 +216,46 @@ export function HeaderCategoryTable({
...
@@ -221,56 +216,46 @@ export function HeaderCategoryTable({
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"w-[120px] text-center"
>
<
TableCell
className=
"w-[120px] text-center"
>
<
DropdownMenu
>
<
AdminRowActions
<
DropdownMenuTrigger
asChild
>
actions=
{
[
<
Button
...(
!
isProtectedHomeCategory
variant=
"ghost"
?
[
className=
"h-8 w-8 p-0 text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
{
>
kind
:
"edit"
as
const
,
<
MoreHorizontal
className=
"h-4 w-4"
/>
label
:
"Chỉnh sửa danh mục"
,
</
Button
>
onClick
:
()
=>
onEdit
(
item
),
</
DropdownMenuTrigger
>
},
<
DropdownMenuContent
align=
"end"
>
]
<
DropdownMenuItem
:
[]),
className=
"text-gray-700 focus:text-[#063e8e]"
...(
canManagePosts
onClick=
{
()
=>
onEdit
(
item
)
}
?
[
>
{
<
Edit
className=
"mr-2 h-4 w-4"
/>
kind
:
"manage"
as
const
,
Chỉnh sửa
label
:
"Quản lý bài viết"
,
</
DropdownMenuItem
>
href
:
`/admin/header-config/${item.id}/posts`
,
},
{
canManagePosts
?
(
]
<
DropdownMenuItem
:
[]),
asChild
...(
canCreateChild
className=
"text-gray-700 focus:text-[#063e8e]"
?
[
>
{
<
Link
href=
{
`/admin/header-config/${item.id}/posts`
}
>
kind
:
"create-child"
as
const
,
<
FileText
className=
"mr-2 h-4 w-4"
/>
label
:
"Thêm danh mục con"
,
Quản lý bài viết
onClick
:
()
=>
onCreateChild
(
item
),
</
Link
>
},
</
DropdownMenuItem
>
]
)
:
null
}
:
[]),
...(
!
isProtectedHomeCategory
{
canCreateChild
?
(
?
[
<
DropdownMenuItem
{
className=
"text-gray-700 focus:text-[#063e8e]"
kind
:
"delete"
as
const
,
onClick=
{
()
=>
onCreateChild
(
item
)
}
label
:
"Xóa danh mục"
,
>
onClick
:
()
=>
onDelete
(
item
),
<
Plus
className=
"mr-2 h-4 w-4"
/>
},
Thêm danh mục con
]
</
DropdownMenuItem
>
:
[]),
)
:
null
}
]
}
/>
<
DropdownMenuSeparator
/>
<
DropdownMenuItem
className=
"text-gray-700 focus:text-[#063e8e]"
onClick=
{
()
=>
onDelete
(
item
)
}
>
<
Trash
className=
"mr-2 h-4 w-4"
/>
Xóa
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
);
);
...
...
src/app/admin/header-config/page.tsx
View file @
82063bf1
...
@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
...
@@ -34,6 +34,12 @@ const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
description
:
""
,
description
:
""
,
};
};
const
PROTECTED_HOME_CATEGORY_ID
=
"root-home"
;
function
isProtectedHomeCategory
(
itemId
?:
string
|
null
)
{
return
itemId
===
PROTECTED_HOME_CATEGORY_ID
;
}
function
toFormValues
(
item
?:
CmsHeaderCategoryItem
|
null
):
HeaderCategoryFormValues
{
function
toFormValues
(
item
?:
CmsHeaderCategoryItem
|
null
):
HeaderCategoryFormValues
{
if
(
!
item
)
return
EMPTY_HEADER_CATEGORY_FORM
;
if
(
!
item
)
return
EMPTY_HEADER_CATEGORY_FORM
;
...
@@ -199,6 +205,8 @@ export default function HeaderConfigPage() {
...
@@ -199,6 +205,8 @@ export default function HeaderConfigPage() {
};
};
const
openEdit
=
(
item
:
HeaderCategoryTreeItem
)
=>
{
const
openEdit
=
(
item
:
HeaderCategoryTreeItem
)
=>
{
if
(
isProtectedHomeCategory
(
item
.
id
))
return
;
const
fullItem
=
itemMap
.
get
(
item
.
id
)
??
null
;
const
fullItem
=
itemMap
.
get
(
item
.
id
)
??
null
;
setFormMode
(
"edit"
);
setFormMode
(
"edit"
);
setFormValues
(
toFormValues
(
fullItem
));
setFormValues
(
toFormValues
(
fullItem
));
...
@@ -223,6 +231,10 @@ export default function HeaderConfigPage() {
...
@@ -223,6 +231,10 @@ export default function HeaderConfigPage() {
const
handleSubmit
=
async
()
=>
{
const
handleSubmit
=
async
()
=>
{
if
(
isSubmitting
)
return
;
if
(
isSubmitting
)
return
;
if
(
isProtectedHomeCategory
(
formValues
.
id
))
{
return
;
}
if
(
!
formValues
.
name
.
trim
())
{
if
(
!
formValues
.
name
.
trim
())
{
toast
.
error
(
"Tên danh mục là bắt buộc"
);
toast
.
error
(
"Tên danh mục là bắt buộc"
);
return
;
return
;
...
@@ -293,6 +305,11 @@ export default function HeaderConfigPage() {
...
@@ -293,6 +305,11 @@ export default function HeaderConfigPage() {
const
handleDelete
=
async
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
||
isSubmitting
)
return
;
if
(
!
deleteTarget
||
isSubmitting
)
return
;
if
(
isProtectedHomeCategory
(
deleteTarget
.
id
))
{
setDeleteTarget
(
null
);
return
;
}
setIsSubmitting
(
true
);
setIsSubmitting
(
true
);
try
{
try
{
...
...
src/app/admin/login/page.tsx
View file @
82063bf1
...
@@ -14,33 +14,17 @@ import {
...
@@ -14,33 +14,17 @@ import {
ShieldCheck
,
ShieldCheck
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
postAuthLogin
}
from
"@/api/endpoints/auth"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Checkbox
}
from
"@/components/ui/checkbox"
;
import
{
Checkbox
}
from
"@/components/ui/checkbox"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
{
loginAdmin
}
from
"@/lib/auth/admin-auth"
;
import
useAuthStore
from
"@/store/useAuthStore"
;
import
useAuthStore
from
"@/store/useAuthStore"
;
type
AuthMode
=
"login"
|
"forgot"
|
"reset"
;
type
AuthMode
=
"login"
|
"forgot"
|
"reset"
;
type
ResetStep
=
"request"
|
"verify"
|
"password"
|
"done"
;
type
ResetStep
=
"request"
|
"verify"
|
"password"
|
"done"
;
type
LoginApiSuccess
=
{
responseData
?:
LoginPayload
;
data
?:
{
responseData
?:
LoginPayload
;
};
};
type
LoginPayload
=
{
access_token
?:
string
;
refresh_token
?:
string
;
expires_in
?:
number
;
user
?:
{
email
?:
string
;
};
};
type
ApiEnvelope
<
T
=
unknown
>
=
{
type
ApiEnvelope
<
T
=
unknown
>
=
{
responseData
?:
T
;
responseData
?:
T
;
data
?:
{
data
?:
{
...
@@ -141,8 +125,8 @@ function AuthShell({
...
@@ -141,8 +125,8 @@ function AuthShell({
mode
===
"login"
mode
===
"login"
?
"Truy cập khu vực quản trị nội dung VCCI News."
?
"Truy cập khu vực quản trị nội dung VCCI News."
:
mode
===
"forgot"
:
mode
===
"forgot"
?
"X
ác thực email quản trị để nhận mã
OTP."
?
"X
?c th?c email qu?n tr? d? nh?n m?
OTP."
:
"Nh
ập mã OTP và tạo mật khẩu mới cho tài khoả
n."
;
:
"Nh
?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?
n."
;
return
(
return
(
<
div
className=
"min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700"
>
<
div
className=
"min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700"
>
...
@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -290,7 +274,6 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const
hasHydrated
=
useAuthStore
((
state
)
=>
state
.
_hasHydrated
);
const
hasHydrated
=
useAuthStore
((
state
)
=>
state
.
_hasHydrated
);
const
isLoggedIn
=
useAuthStore
((
state
)
=>
state
.
appIsLoggedIn
);
const
isLoggedIn
=
useAuthStore
((
state
)
=>
state
.
appIsLoggedIn
);
const
rememberState
=
useAuthStore
((
state
)
=>
state
.
appUserRemember
);
const
rememberState
=
useAuthStore
((
state
)
=>
state
.
appUserRemember
);
const
setAppToken
=
useAuthStore
((
state
)
=>
state
.
setAppToken
);
const
setAppUserRemember
=
useAuthStore
((
state
)
=>
state
.
setAppUserRemember
);
const
setAppUserRemember
=
useAuthStore
((
state
)
=>
state
.
setAppUserRemember
);
const
[
mode
,
setMode
]
=
useState
<
AuthMode
>
(
"login"
);
const
[
mode
,
setMode
]
=
useState
<
AuthMode
>
(
"login"
);
...
@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -329,18 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading
(
true
);
setLoginLoading
(
true
);
try
{
try
{
const
response
=
await
postAuthLogin
({
await
loginAdmin
(
email
.
trim
(),
password
);
email
:
email
.
trim
(),
password
,
});
const
payload
=
getResponseData
<
LoginPayload
>
(
response
as
unknown
as
LoginApiSuccess
);
if
(
!
payload
?.
access_token
||
!
payload
.
expires_in
)
{
throw
new
Error
(
"Thiếu dữ liệu token từ API đăng nhập."
);
}
setAppToken
(
payload
.
access_token
,
payload
.
expires_in
,
payload
.
refresh_token
);
setAppUserRemember
(
remember
?
email
.
trim
()
:
""
,
remember
?
password
:
""
,
remember
);
setAppUserRemember
(
remember
?
email
.
trim
()
:
""
,
remember
?
password
:
""
,
remember
);
toast
.
success
(
"Đăng nhập quản trị thành công"
);
toast
.
success
(
"Đăng nhập quản trị thành công"
);
...
@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -365,9 +337,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep
(
"verify"
);
setResetStep
(
"verify"
);
setMode
(
"reset"
);
setMode
(
"reset"
);
setResetMessage
(
"M
ã OTP đã được gửi đến email quản trị
."
);
setResetMessage
(
"M
? OTP d? du?c g?i d?n email qu?n tr?
."
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setResetError
(
getAuthErrorMessage
(
error
,
"Kh
ông thể gửi mã OTP. Vui lòng thử lạ
i."
));
setResetError
(
getAuthErrorMessage
(
error
,
"Kh
?ng th? g?i m? OTP. Vui l?ng th? l?
i."
));
}
finally
{
}
finally
{
setResetLoading
(
false
);
setResetLoading
(
false
);
}
}
...
@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -390,14 +362,14 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const
payload
=
getResponseData
<
VerifyOtpPayload
>
(
response
);
const
payload
=
getResponseData
<
VerifyOtpPayload
>
(
response
);
if
(
!
payload
?.
reset_token
)
{
if
(
!
payload
?.
reset_token
)
{
throw
new
Error
(
"Kh
ông nhận được mã đặt lại mật khẩu từ
API."
);
throw
new
Error
(
"Kh
?ng nh?n du?c m? d?t l?i m?t kh?u t?
API."
);
}
}
setResetToken
(
payload
.
reset_token
);
setResetToken
(
payload
.
reset_token
);
setResetStep
(
"password"
);
setResetStep
(
"password"
);
setResetMessage
(
"OTP hợp lệ. Bạn có thể tạo mật khẩu mới."
);
setResetMessage
(
"OTP hợp lệ. Bạn có thể tạo mật khẩu mới."
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setResetError
(
getAuthErrorMessage
(
error
,
"OTP kh
ông hợp lệ hoặc đã hết hạ
n."
));
setResetError
(
getAuthErrorMessage
(
error
,
"OTP kh
?ng h?p l? ho?c d? h?t h?
n."
));
}
finally
{
}
finally
{
setResetLoading
(
false
);
setResetLoading
(
false
);
}
}
...
@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -571,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP...
Đang gửi OTP...
</>
</>
)
:
(
)
:
(
"G
ửi mã
OTP"
"G
?i m?
OTP"
)
}
)
}
</
Button
>
</
Button
>
...
@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -621,7 +593,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<
form
className=
"space-y-5"
onSubmit=
{
handleVerifyOtp
}
>
<
form
className=
"space-y-5"
onSubmit=
{
handleVerifyOtp
}
>
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"otp"
className=
"text-gray-700"
>
<
Label
htmlFor=
"otp"
className=
"text-gray-700"
>
M
ã
OTP
M
?
OTP
</
Label
>
</
Label
>
<
Input
<
Input
id=
"otp"
id=
"otp"
...
@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -650,7 +622,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
onClick=
{
switchToForgot
}
onClick=
{
switchToForgot
}
className=
"h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
className=
"h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
>
G
ửi lại mã
OTP
G
?i l?i m?
OTP
</
Button
>
</
Button
>
</
form
>
</
form
>
)
:
null
}
)
:
null
}
...
@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -700,7 +672,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<
div
className=
"space-y-5"
>
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center"
>
<
CheckCircle2
className=
"mx-auto h-10 w-10 text-[#063e8e]"
/>
<
CheckCircle2
className=
"mx-auto h-10 w-10 text-[#063e8e]"
/>
<
div
className=
"mt-3 text-base font-semibold text-gray-900"
>
M
ật khẩu đã được cập nhậ
t
</
div
>
<
div
className=
"mt-3 text-base font-semibold text-gray-900"
>
M
?t kh?u d? du?c c?p nh?
t
</
div
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu mới.
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu mới.
</
p
>
</
p
>
...
...
src/app/admin/media/page.tsx
View file @
82063bf1
...
@@ -429,7 +429,7 @@ export default function AdminMediaPage() {
...
@@ -429,7 +429,7 @@ export default function AdminMediaPage() {
</
div
>
</
div
>
<
h2
className=
"mt-5 text-lg font-semibold text-slate-800"
>
Chưa có ảnh phù hợp
</
h2
>
<
h2
className=
"mt-5 text-lg font-semibold text-slate-800"
>
Chưa có ảnh phù hợp
</
h2
>
<
p
className=
"mt-2 max-w-md text-sm leading-6 text-slate-500"
>
<
p
className=
"mt-2 max-w-md text-sm leading-6 text-slate-500"
>
H
ãy tải ảnh mới hoặc thử lại với từ khóa khác để tìm đúng hình ảnh bạn cầ
n.
H
?y t?i ?nh m?i ho?c th? l?i v?i t? kh?a kh?c d? t?m d?ng h?nh ?nh b?n c?
n.
</
p
>
</
p
>
</
div
>
</
div
>
)
:
(
)
:
(
...
...
src/app/admin/members/fields/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Plus
,
Save
,
Trash2
,
X
}
from
"lucide-react"
;
import
{
Plus
,
Save
,
X
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() {
...
@@ -206,26 +207,12 @@ export default function AdminMemberFieldsPage() {
{
item
.
name
}
{
item
.
name
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"edit"
,
label
:
"Chỉnh sửa lĩnh vực"
,
onClick
:
()
=>
openEdit
(
item
)
},
variant=
"ghost"
{
kind
:
"delete"
,
label
:
"Xóa lĩnh vực"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
size=
"icon"
]
}
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
/>
onClick=
{
()
=>
openEdit
(
item
)
}
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/members/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
MoreHorizontal
,
Plus
,
Trash2
,
Users
}
from
"lucide-react"
;
import
{
Plus
,
Users
}
from
"lucide-react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
DropdownMenu
,
DropdownMenuContent
,
DropdownMenuItem
,
DropdownMenuSeparator
,
DropdownMenuTrigger
,
}
from
"@/components/ui/dropdown-menu"
;
import
{
import
{
Select
,
Select
,
SelectContent
,
SelectContent
,
...
@@ -258,34 +252,20 @@ export default function AdminMembersPage() {
...
@@ -258,34 +252,20 @@ export default function AdminMembersPage() {
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
DropdownMenu
>
<
AdminRowActions
<
DropdownMenuTrigger
asChild
>
actions=
{
[
<
Button
{
variant=
"ghost"
kind
:
"edit"
,
size=
"icon"
label
:
"Chỉnh sửa hội viên"
,
className=
"h-8 w-8 hover:bg-[#063e8e]/10"
onClick
:
()
=>
router
.
push
(
`/admin/members/${item.id}`
),
>
},
<
MoreHorizontal
className=
"h-4 w-4"
/>
{
</
Button
>
kind
:
"delete"
,
</
DropdownMenuTrigger
>
label
:
"Xóa hội viên"
,
<
DropdownMenuContent
align=
"end"
className=
"border-[#063e8e]/15"
>
onClick
:
()
=>
setDeleteTarget
(
item
),
<
DropdownMenuItem
},
className=
"cursor-pointer text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
]
}
onClick=
{
()
=>
router
.
push
(
`/admin/members/${item.id}`
)
}
/>
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Chỉnh sửa
</
DropdownMenuItem
>
<
DropdownMenuSeparator
/>
<
DropdownMenuItem
className=
"cursor-pointer text-red-600 focus:bg-red-50 focus:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"mr-2 h-4 w-4"
/>
Xóa
</
DropdownMenuItem
>
</
DropdownMenuContent
>
</
DropdownMenu
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/members/regions/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Plus
,
Save
,
Trash2
,
X
}
from
"lucide-react"
;
import
{
Plus
,
Save
,
X
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() {
...
@@ -206,26 +207,12 @@ export default function AdminMemberRegionsPage() {
{
item
.
name
}
{
item
.
name
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"edit"
,
label
:
"Chỉnh sửa khu vực"
,
onClick
:
()
=>
openEdit
(
item
)
},
variant=
"ghost"
{
kind
:
"delete"
,
label
:
"Xóa khu vực"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
size=
"icon"
]
}
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
/>
onClick=
{
()
=>
openEdit
(
item
)
}
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/app/admin/news/page.tsx
View file @
82063bf1
This diff is collapsed.
Click to expand it.
src/app/admin/tags/page.tsx
View file @
82063bf1
...
@@ -2,9 +2,10 @@
...
@@ -2,9 +2,10 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Edit2
,
Hash
,
Plus
,
Tag
,
Trash2
}
from
"lucide-react"
;
import
{
ChevronLeft
,
ChevronRight
,
Hash
,
Plus
,
Tag
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
...
@@ -30,7 +31,7 @@ import {
...
@@ -30,7 +31,7 @@ import {
type
CmsTagItem
,
type
CmsTagItem
,
createCmsTag
,
createCmsTag
,
deleteCmsTag
,
deleteCmsTag
,
fetchCmsTags
,
fetchCmsTags
Page
,
updateCmsTag
,
updateCmsTag
,
}
from
"@/lib/api/cms-admin"
;
}
from
"@/lib/api/cms-admin"
;
...
@@ -68,12 +69,17 @@ export default function AdminTagsPage() {
...
@@ -68,12 +69,17 @@ export default function AdminTagsPage() {
const
[
formOpen
,
setFormOpen
]
=
React
.
useState
(
false
);
const
[
formOpen
,
setFormOpen
]
=
React
.
useState
(
false
);
const
[
formValues
,
setFormValues
]
=
React
.
useState
<
TagFormValues
>
(
EMPTY_FORM
);
const
[
formValues
,
setFormValues
]
=
React
.
useState
<
TagFormValues
>
(
EMPTY_FORM
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
CmsTagItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
CmsTagItem
|
null
>
(
null
);
const
[
page
,
setPage
]
=
React
.
useState
(
1
);
const
[
pageSize
]
=
React
.
useState
(
10
);
const
[
total
,
setTotal
]
=
React
.
useState
(
0
);
const
load
=
React
.
useCallback
(
async
()
=>
{
const
load
=
React
.
useCallback
(
async
()
=>
{
const
nextItems
=
await
fetchCmsTags
();
setIsReady
(
false
);
setItems
(
nextItems
);
const
result
=
await
fetchCmsTagsPage
({
page
,
pageSize
});
setItems
(
result
.
items
);
setTotal
(
result
.
total
);
setIsReady
(
true
);
setIsReady
(
true
);
},
[]);
},
[
page
,
pageSize
]);
React
.
useEffect
(()
=>
{
React
.
useEffect
(()
=>
{
void
load
().
catch
((
error
)
=>
{
void
load
().
catch
((
error
)
=>
{
...
@@ -93,6 +99,18 @@ export default function AdminTagsPage() {
...
@@ -93,6 +99,18 @@ export default function AdminTagsPage() {
);
);
},
[
items
,
search
]);
},
[
items
,
search
]);
const
totalPages
=
Math
.
ceil
(
total
/
pageSize
);
React
.
useEffect
(()
=>
{
setPage
((
currentPage
)
=>
(
currentPage
===
1
?
currentPage
:
1
));
},
[
search
]);
const
handlePageChange
=
(
newPage
:
number
)
=>
{
if
(
newPage
>=
1
&&
newPage
<=
totalPages
)
{
setPage
(
newPage
);
}
};
const
openCreate
=
()
=>
{
const
openCreate
=
()
=>
{
setFormValues
(
EMPTY_FORM
);
setFormValues
(
EMPTY_FORM
);
setFormOpen
(
true
);
setFormOpen
(
true
);
...
@@ -176,7 +194,7 @@ export default function AdminTagsPage() {
...
@@ -176,7 +194,7 @@ export default function AdminTagsPage() {
actionDisabled=
{
!
isReady
}
actionDisabled=
{
!
isReady
}
actionMeta=
{
actionMeta=
{
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]"
>
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]"
>
Tổng số tags:
{
items
.
length
}
Tổng số tags:
{
total
}
</
div
>
</
div
>
}
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
...
@@ -238,32 +256,77 @@ export default function AdminTagsPage() {
...
@@ -238,32 +256,77 @@ export default function AdminTagsPage() {
{
item
.
updated_at
?
dayjs
(
item
.
updated_at
).
format
(
"DD/MM/YYYY"
)
:
"-"
}
{
item
.
updated_at
?
dayjs
(
item
.
updated_at
).
format
(
"DD/MM/YYYY"
)
:
"-"
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-4"
>
<
TableCell
className=
"px-4 py-4"
>
<
div
className=
"flex justify-center gap-2"
>
<
AdminRowActions
actions=
{
[
{
kind
:
"edit"
,
label
:
"Chỉnh sửa tag"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa tag"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
]
}
/>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
{
totalPages
>
1
&&
(
<
div
className=
"flex items-center justify-between border-t border-[#063e8e]/10 px-4 py-3"
>
<
div
className=
"text-sm text-gray-700"
>
Hiển thị
{
(
page
-
1
)
*
pageSize
+
1
}
đến
{
" "
}
{
Math
.
min
(
page
*
pageSize
,
total
)
}
của
{
total
}
tag
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
Button
<
Button
type=
"button"
variant=
"outline"
variant=
"outline"
size=
"icon"
size=
"icon"
className=
"h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
className=
"h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick=
{
()
=>
openEdit
(
item
)
}
onClick=
{
()
=>
handlePageChange
(
page
-
1
)
}
disabled=
{
page
===
1
}
>
>
<
Edit2
className=
"h-4 w-4"
/>
<
ChevronLeft
className=
"h-4 w-4"
/>
</
Button
>
</
Button
>
<
div
className=
"flex items-center gap-1"
>
{
Array
.
from
({
length
:
Math
.
min
(
totalPages
,
5
)
},
(
_
,
index
)
=>
{
let
pageNum
;
if
(
totalPages
<=
5
)
{
pageNum
=
index
+
1
;
}
else
if
(
page
<=
3
)
{
pageNum
=
index
+
1
;
}
else
if
(
page
>=
totalPages
-
2
)
{
pageNum
=
totalPages
-
4
+
index
;
}
else
{
pageNum
=
page
-
2
+
index
;
}
return
(
<
Button
key=
{
pageNum
}
variant=
{
page
===
pageNum
?
"default"
:
"outline"
}
size=
"icon"
className=
{
page
===
pageNum
?
"h-8 w-8 bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
:
"h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
}
onClick=
{
()
=>
handlePageChange
(
pageNum
)
}
>
{
pageNum
}
</
Button
>
);
})
}
</
div
>
<
Button
<
Button
type=
"button"
variant=
"outline"
variant=
"outline"
size=
"icon"
size=
"icon"
className=
"h-8 w-8 border-red-100 bg-white text-red-600 hover:bg-red-50"
className=
"h-8 w-8 border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#063e8e]/10"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
onClick=
{
()
=>
handlePageChange
(
page
+
1
)
}
disabled=
{
page
===
totalPages
}
>
>
<
Trash2
className=
"h-4 w-4"
/>
<
ChevronRight
className=
"h-4 w-4"
/>
</
Button
>
</
Button
>
</
div
>
</
div
>
</
TableCell
>
</
div
>
</
TableRow
>
))
)
}
)
}
</
TableBody
>
</
Table
>
</
AdminTableLayout
>
</
AdminTableLayout
>
<
Dialog
open=
{
formOpen
}
onOpenChange=
{
setFormOpen
}
>
<
Dialog
open=
{
formOpen
}
onOpenChange=
{
setFormOpen
}
>
...
...
src/app/admin/videos/page.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Plus
,
Save
,
Trash2
,
Video
,
X
}
from
"lucide-react"
;
import
{
Plus
,
Save
,
Video
,
X
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -273,26 +274,12 @@ export default function AdminVideosPage() {
...
@@ -273,26 +274,12 @@ export default function AdminVideosPage() {
</
Link
>
</
Link
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
AdminRowActions
<
Button
actions=
{
[
type=
"button"
{
kind
:
"edit"
,
label
:
"Chỉnh sửa video"
,
onClick
:
()
=>
openEdit
(
item
)
},
variant=
"ghost"
{
kind
:
"delete"
,
label
:
"Xóa video"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
size=
"icon"
]
}
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
/>
onClick=
{
()
=>
openEdit
(
item
)
}
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
))
))
...
...
src/components/admin/admin-delete-dialog.tsx
View file @
82063bf1
...
@@ -33,14 +33,20 @@ export function AdminDeleteDialog({
...
@@ -33,14 +33,20 @@ export function AdminDeleteDialog({
}:
AdminDeleteDialogProps
)
{
}:
AdminDeleteDialogProps
)
{
return
(
return
(
<
AlertDialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
AlertDialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
AlertDialogContent
>
<
AlertDialogContent
className=
"rounded-3xl border border-[#063e8e]/15 bg-white p-6 shadow-xl"
>
<
AlertDialogHeader
>
<
AlertDialogHeader
>
<
AlertDialogTitle
>
{
title
}
</
AlertDialogTitle
>
<
AlertDialogTitle
className=
"text-xl font-semibold text-gray-900"
>
<
AlertDialogDescription
>
{
description
}
</
AlertDialogDescription
>
{
title
}
</
AlertDialogTitle
>
<
AlertDialogDescription
className=
"text-sm leading-6 text-gray-700"
>
{
description
}
</
AlertDialogDescription
>
</
AlertDialogHeader
>
</
AlertDialogHeader
>
<
AlertDialogFooter
>
<
AlertDialogFooter
className=
"mt-2 gap-2"
>
<
AlertDialogCancel
>
{
cancelLabel
}
</
AlertDialogCancel
>
<
AlertDialogCancel
className=
"mt-0 border-[#063e8e]/15 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900"
>
<
AlertDialogAction
className=
"bg-[#063e8e] hover:bg-[#063e8e]/90"
onClick=
{
onConfirm
}
>
{
cancelLabel
}
</
AlertDialogCancel
>
<
AlertDialogAction
className=
"bg-red-600 text-white hover:bg-red-700"
onClick=
{
onConfirm
}
>
{
confirmLabel
}
{
confirmLabel
}
</
AlertDialogAction
>
</
AlertDialogAction
>
</
AlertDialogFooter
>
</
AlertDialogFooter
>
...
...
src/components/admin/admin-row-actions.tsx
0 → 100644
View file @
82063bf1
"use client"
;
import
type
{
ReactNode
}
from
"react"
;
import
Link
from
"next/link"
;
import
{
Eye
,
FileText
,
FolderPlus
,
PencilLine
,
Trash2
,
}
from
"lucide-react"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
cn
}
from
"@/lib/utils"
;
type
AdminRowActionKind
=
"edit"
|
"view"
|
"delete"
|
"manage"
|
"create-child"
;
type
AdminRowActionBase
=
{
label
:
string
;
disabled
?:
boolean
;
};
type
AdminRowAction
=
|
(
AdminRowActionBase
&
{
kind
:
"edit"
;
onClick
:
()
=>
void
;
})
|
(
AdminRowActionBase
&
{
kind
:
"view"
;
onClick
:
()
=>
void
;
})
|
(
AdminRowActionBase
&
{
kind
:
"delete"
;
onClick
:
()
=>
void
;
})
|
(
AdminRowActionBase
&
{
kind
:
"manage"
;
href
:
string
;
})
|
(
AdminRowActionBase
&
{
kind
:
"create-child"
;
onClick
:
()
=>
void
;
});
interface
AdminRowActionsProps
{
actions
:
AdminRowAction
[];
className
?:
string
;
}
const
actionStyles
:
Record
<
AdminRowActionKind
,
{
button
:
string
;
icon
:
ReactNode
;
}
>
=
{
edit
:
{
button
:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
,
icon
:
<
PencilLine
className=
"h-4 w-4"
/>,
},
view
:
{
button
:
"border-emerald-100 bg-white text-emerald-600 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700"
,
icon
:
<
Eye
className=
"h-4 w-4"
/>,
},
delete
:
{
button
:
"border-red-100 bg-white text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700"
,
icon
:
<
Trash2
className=
"h-4 w-4"
/>,
},
manage
:
{
button
:
"border-[#063e8e]/15 bg-white text-[#063e8e] hover:border-[#063e8e]/25 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
,
icon
:
<
FileText
className=
"h-4 w-4"
/>,
},
"create-child"
:
{
button
:
"border-sky-100 bg-white text-sky-600 hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700"
,
icon
:
<
FolderPlus
className=
"h-4 w-4"
/>,
},
};
function
AdminRowActionButton
({
action
}:
{
action
:
AdminRowAction
})
{
const
style
=
actionStyles
[
action
.
kind
];
const
sharedClassName
=
cn
(
"h-8 w-8 rounded-lg shadow-sm"
,
style
.
button
);
if
(
action
.
kind
===
"manage"
)
{
return
(
<
Button
asChild
type=
"button"
variant=
"outline"
size=
"icon"
title=
{
action
.
label
}
aria
-
label=
{
action
.
label
}
disabled=
{
action
.
disabled
}
className=
{
sharedClassName
}
>
<
Link
href=
{
action
.
href
}
>
{
style
.
icon
}
</
Link
>
</
Button
>
);
}
return
(
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
title=
{
action
.
label
}
aria
-
label=
{
action
.
label
}
disabled=
{
action
.
disabled
}
onClick=
{
action
.
onClick
}
className=
{
sharedClassName
}
>
{
style
.
icon
}
</
Button
>
);
}
export
function
AdminRowActions
({
actions
,
className
}:
AdminRowActionsProps
)
{
if
(
actions
.
length
===
0
)
return
null
;
return
(
<
div
className=
{
cn
(
"flex items-center justify-center gap-1.5"
,
className
)
}
>
{
actions
.
map
((
action
)
=>
(
<
AdminRowActionButton
key=
{
`${action.kind}-${action.label}`
}
action=
{
action
}
/>
))
}
</
div
>
);
}
src/components/admin/admin-table-layout.tsx
View file @
82063bf1
src/components/admin/image-picker.tsx
View file @
82063bf1
...
@@ -143,7 +143,7 @@ export function AdminImagePicker({
...
@@ -143,7 +143,7 @@ export function AdminImagePicker({
<
ImagePlus
className=
"mb-3 h-10 w-10 text-[#063e8e]"
/>
<
ImagePlus
className=
"mb-3 h-10 w-10 text-[#063e8e]"
/>
<
p
className=
"text-base font-medium text-black"
>
Chưa có hình ảnh phù hợp
</
p
>
<
p
className=
"text-base font-medium text-black"
>
Chưa có hình ảnh phù hợp
</
p
>
<
p
className=
"mt-1 text-sm text-gray-700"
>
<
p
className=
"mt-1 text-sm text-gray-700"
>
H
ãy thử từ khóa khác hoặc tải thêm hình ảnh vào thư việ
n.
H
?y th? t? kh?a kh?c ho?c t?i th?m h?nh ?nh v?o thu vi?
n.
</
p
>
</
p
>
</
div
>
</
div
>
)
:
(
)
:
(
...
@@ -172,7 +172,7 @@ export function AdminImagePicker({
...
@@ -172,7 +172,7 @@ export function AdminImagePicker({
/>
/>
{
item
.
id
===
selectedId
?
(
{
item
.
id
===
selectedId
?
(
<
div
className=
"absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white"
>
<
div
className=
"absolute right-3 top-3 rounded-full bg-[#063e8e] px-2 py-1 text-xs font-medium text-white"
>
Đã chọ
n
?? ch?
n
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
...
...
src/components/admin/news-form.tsx
View file @
82063bf1
...
@@ -521,9 +521,14 @@ export function AdminNewsForm({
...
@@ -521,9 +521,14 @@ export function AdminNewsForm({
title
:
form
.
title
.
trim
(),
title
:
form
.
title
.
trim
(),
slug
:
slugifyAdminNews
(
form
.
slug
.
trim
()),
slug
:
slugifyAdminNews
(
form
.
slug
.
trim
()),
summary
:
form
.
summary
,
summary
:
form
.
summary
,
type
:
form
.
type
,
header_category_id
:
form
.
header_category_id
,
header_category_id
:
form
.
header_category_id
,
category_ids
:
category_ids
:
form
.
type
===
"baiviettrang"
?
[]
:
selectedHeaderCategory
?.
category_ids
??
[],
form
.
type
===
"baiviettrang"
?
form
.
header_category_id
?
[
form
.
header_category_id
]
:
[]
:
selectedHeaderCategory
?.
category_ids
??
[],
tag_ids
:
form
.
type
===
"baiviettrang"
?
[]
:
selectedTagIds
,
tag_ids
:
form
.
type
===
"baiviettrang"
?
[]
:
selectedTagIds
,
is_featured
:
form
.
type
===
"tintuc"
?
form
.
is_featured
:
false
,
is_featured
:
form
.
type
===
"tintuc"
?
form
.
is_featured
:
false
,
thumbnail_id
:
form
.
thumbnail
&&
isUuid
(
form
.
thumbnail
.
id
)
?
form
.
thumbnail
.
id
:
null
,
thumbnail_id
:
form
.
thumbnail
&&
isUuid
(
form
.
thumbnail
.
id
)
?
form
.
thumbnail
.
id
:
null
,
...
@@ -812,8 +817,11 @@ export function AdminNewsForm({
...
@@ -812,8 +817,11 @@ export function AdminNewsForm({
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
{
availableSearchTags
.
length
>
0
?
(
{
availableSearchTags
.
length
>
0
?
(
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4
"
>
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4 xl:col-span-2
"
>
<
Label
className=
"mb-3 block text-gray-700"
>
Tag tìm kiếm
</
Label
>
<
Label
className=
"mb-3 block text-gray-700"
>
Tag tìm kiếm
</
Label
>
<
div
className=
"grid grid-cols-1 gap-2 md:grid-cols-2"
>
<
div
className=
"grid grid-cols-1 gap-2 md:grid-cols-2"
>
{
availableSearchTags
.
map
((
item
)
=>
(
{
availableSearchTags
.
map
((
item
)
=>
(
...
@@ -835,8 +843,6 @@ export function AdminNewsForm({
...
@@ -835,8 +843,6 @@ export function AdminNewsForm({
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
</
FormSection
>
</
FormSection
>
<
FormSection
<
FormSection
...
...
src/components/base/card-news/index.tsx
View file @
82063bf1
import
dayjs
from
"dayjs"
;
import
Links
from
"@links/index"
;
import
{
NewsItem
}
from
"@/api/types/news"
;
import
{
NewsItem
}
from
'@/api/types/news'
;
import
Links
from
'@links/index'
import
dayjs
from
'dayjs'
;
// Helper: remove <img> tags and extract plain text from HTML
const
stripImagesAndHtml
=
(
html
?:
string
)
=>
{
const
stripImagesAndHtml
=
(
html
?:
string
)
=>
{
if
(
!
html
)
return
''
if
(
!
html
)
return
""
;
// remove img tags first
const
withoutIm
gs
=
html
.
replace
(
/<img
[^
>
]
*>/gi
,
''
)
const
withoutIm
ages
=
html
.
replace
(
/<img
[^
>
]
*>/gi
,
""
);
// use DOMParser on client for robust extraction
if
(
typeof
window
!==
'undefined'
&&
typeof
DOMParser
!==
'undefined'
)
{
if
(
typeof
window
!==
"undefined"
&&
typeof
DOMParser
!==
"undefined"
)
{
try
{
try
{
const
doc
=
new
DOMParser
().
parseFromString
(
withoutIm
gs
,
'text/html'
)
const
doc
=
new
DOMParser
().
parseFromString
(
withoutIm
ages
,
"text/html"
);
return
doc
.
body
.
textContent
||
''
return
doc
.
body
.
textContent
||
""
;
}
catch
{
}
catch
{
// fallback to regex
return
withoutImages
.
replace
(
/<
[^
>
]
*>/g
,
""
);
}
}
}
}
return
withoutImgs
.
replace
(
/<
[^
>
]
*>/g
,
''
)
}
const
CardNews
=
({
news
,
link
}:
{
news
:
NewsItem
,
link
:
string
})
=>
{
return
withoutImages
.
replace
(
/<
[^
>
]
*>/g
,
""
);
};
const
resolveThumbnail
=
(
thumbnail
?:
string
)
=>
{
if
(
!
thumbnail
)
return
"/img-error.png"
;
if
(
thumbnail
.
startsWith
(
"http://"
)
||
thumbnail
.
startsWith
(
"https://"
))
return
thumbnail
;
if
(
thumbnail
.
startsWith
(
"/"
))
return
`
${
Links
.
imageEndpoint
.
replace
(
/
\/
+$/
,
""
)}${
thumbnail
}
`
;
return
`
${
Links
.
imageEndpoint
}${
thumbnail
}
`
;
};
const
CardNews
=
({
news
,
link
}:
{
news
:
NewsItem
;
link
:
string
})
=>
{
return
(
return
(
<
a
<
a
href=
{
`${link}`
}
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"
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
<
img
src=
{
`${Links.imageEndpoint}${news.thumbnail}`
}
src=
{
resolveThumbnail
(
news
.
thumbnail
)
}
alt=
{
news
.
title
}
alt=
{
news
.
title
}
className=
"w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
className=
"w-full sm:w-56 md:w-64 h-40 md:h-36 object-cover shrink-0"
onError=
{
(
e
)
=>
{
onError=
{
(
e
)
=>
{
e
.
currentTarget
.
src
=
"/img-error.png"
e
.
currentTarget
.
src
=
"/img-error.png"
;
}
}
}
}
/>
/>
<
div
className=
"flex-1 min-w-0 pl-0 sm:pl-4"
>
<
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
"
>
<
p
className=
"text-primary font-semibold text-base md:text-lg hover:underline line-clamp-2
break-words
"
>
{
news
.
title
}
{
news
.
title
}
</
p
>
</
p
>
<
div
className=
"text-sm my-2 text-[#00AED5]"
>
{
dayjs
(
news
.
release_at
).
format
(
'DD/MM/YYYY'
)
}
</
div
>
<
div
className=
"text-sm my-2 text-[#00AED5]"
>
{
dayjs
(
news
.
release_at
).
format
(
"DD/MM/YYYY"
)
}
</
div
>
<
div
className=
"text-sm text-[#777] line-clamp-3"
>
<
div
className=
"text-sm text-[#777] line-clamp-3"
>
<
div
className=
"text-sm prose tiptap"
>
{
stripImagesAndHtml
(
news
.
description
)
}
</
div
>
<
div
className=
"text-sm prose tiptap"
>
{
stripImagesAndHtml
(
news
.
description
)
}
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
a
>
</
a
>
)
)
;
}
}
;
export
default
CardNews
;
export
default
CardNews
;
src/components/base/list-category/index.tsx
View file @
82063bf1
...
@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category";
...
@@ -7,31 +7,29 @@ import { MenuItem } from "../menu-category";
type
Category
=
{
type
Category
=
{
id
:
string
;
id
:
string
;
name
:
string
;
name
:
string
;
static_link
:
string
;
static_link
?
:
string
;
url
?:
string
;
};
};
// Default categories removed — component now accepts `categories` via props.
const
resolveHref
=
(
category
:
Category
)
=>
category
.
static_link
??
category
.
url
??
"#"
;
const
ListCategory
:
React
.
FC
<
{
categories
?:
Category
[]
}
>
=
({
const
ListCategory
:
React
.
FC
<
{
categories
?:
Category
[]
}
>
=
({
categories
=
[]
})
=>
{
categories
=
[],
})
=>
{
const
pathname
=
usePathname
()
||
""
;
const
pathname
=
usePathname
()
||
""
;
const
isActive
=
(
href
:
string
)
=>
{
const
isActive
=
(
href
:
string
)
=>
pathname
===
href
;
return
pathname
===
href
;
};
return
(
return
(
<
div
className=
"border-t border-gray-200 bg-white py-2"
>
<
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=
"w-full px-4 sm:px-6 lg:px-8"
>
<
div
className=
"py-3"
>
<
div
className=
"py-3"
>
<
div
className=
"flex flex-wrap items-center max-w-full overflow-x-auto"
>
<
div
className=
"flex flex-wrap items-center max-w-full overflow-x-auto"
>
{
categories
.
map
((
c
)
=>
{
{
categories
.
map
((
category
)
=>
{
const
menu
=
{
id
:
c
.
id
,
name
:
c
.
name
,
link
:
c
.
static_link
};
const
href
=
resolveHref
(
category
);
const
active
=
isActive
(
c
.
static_link
);
const
menu
=
{
id
:
category
.
id
,
name
:
category
.
name
,
link
:
href
};
const
active
=
isActive
(
href
);
return
(
return
(
<
div
key=
{
c
.
id
}
className=
"shrink-0"
>
<
div
key=
{
c
ategory
.
id
}
className=
"shrink-0"
>
<
MenuItem
menu=
{
menu
}
active=
{
active
}
/>
<
MenuItem
menu=
{
menu
}
active=
{
active
}
/>
</
div
>
</
div
>
);
);
...
...
src/components/base/list-filter/index.tsx
View file @
82063bf1
"use client"
"use client"
;
import
React
,
{
useState
,
useEffect
}
from
'react'
import
{
Checkbox
}
from
'@/components/ui/checkbox'
import
{
Input
}
from
'@/components/ui/input'
import
{
Button
}
from
'@/components/ui/button'
type
Category
=
{
id
:
string
;
title
:
string
;
count
:
number
}
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
{
Checkbox
}
from
"@/components/ui/checkbox"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Button
}
from
"@/components/ui/button"
;
type
Category
=
{
id
:
string
;
title
:
string
;
count
:
number
};
export
const
ListFilter
:
React
.
FC
<
{
export
const
ListFilter
:
React
.
FC
<
{
categories
?:
Category
[]
categories
?:
Category
[]
;
onSearch
?:
(
q
:
string
)
=>
void
onSearch
?:
(
q
:
string
)
=>
void
;
onReset
?:
()
=>
void
onReset
?:
()
=>
void
;
}
>
=
({
categories
,
onSearch
,
onReset
})
=>
{
}
>
=
({
categories
,
onSearch
,
onReset
})
=>
{
const
[
query
,
setQuery
]
=
useState
(
''
)
const
[
query
,
setQuery
]
=
useState
(
""
);
const
[
visibleCount
,
setVisibleCount
]
=
useState
(
5
)
const
[
visibleCount
,
setVisibleCount
]
=
useState
(
5
)
;
const
[
selected
,
setSelected
]
=
useState
<
Record
<
string
,
boolean
>>
(()
=>
{
const
[
selected
,
setSelected
]
=
useState
<
Record
<
string
,
boolean
>>
(()
=>
{
const
map
:
Record
<
string
,
boolean
>
=
{}
const
map
:
Record
<
string
,
boolean
>
=
{};
if
(
categories
&&
categories
.
length
)
{
if
(
categories
?.
length
)
{
categories
.
forEach
((
c
:
Category
)
=>
(
map
[
c
.
id
]
=
false
))
categories
.
forEach
((
category
)
=>
{
map
[
category
.
id
]
=
false
;
});
}
}
return
map
return
map
;
})
})
;
// Keep selected map in sync when categories prop changes.
// Defer setSelected to avoid calling setState synchronously inside the effect.
useEffect
(()
=>
{
useEffect
(()
=>
{
const
timer
=
setTimeout
(()
=>
{
const
timer
=
setTimeout
(()
=>
{
setSelected
((
prev
)
=>
{
setSelected
((
prev
)
=>
{
const
map
:
Record
<
string
,
boolean
>
=
{}
const
map
:
Record
<
string
,
boolean
>
=
{};
if
(
categories
&&
categories
.
length
)
{
if
(
categories
?.
length
)
{
categories
.
forEach
((
c
:
Category
)
=>
(
map
[
c
.
id
]
=
!!
prev
[
c
.
id
]))
categories
.
forEach
((
category
)
=>
{
map
[
category
.
id
]
=
Boolean
(
prev
[
category
.
id
]);
});
}
}
return
map
return
map
;
})
});
},
0
)
},
0
);
return
()
=>
clearTimeout
(
timer
)
},
[
categories
])
return
()
=>
clearTimeout
(
timer
);
},
[
categories
]);
const
toggle
=
(
id
:
string
)
=>
setSelected
((
s
)
=>
({
...
s
,
[
id
]:
!
s
[
id
]
}))
const
toggle
=
(
id
:
string
)
=>
setSelected
((
current
)
=>
({
...
current
,
[
id
]:
!
current
[
id
]
}));
return
(
return
(
<
aside
className=
"p-6 bg-white border rounded-md"
>
<
aside
className=
"p-6 bg-white border rounded-md"
>
...
@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{
...
@@ -44,74 +48,82 @@ export const ListFilter: React.FC<{
<
div
className=
"mb-4"
>
<
div
className=
"mb-4"
>
<
Input
<
Input
placeholder=
"Tên văn bản
..."
placeholder=
"Tên văn bản..."
value=
{
query
}
value=
{
query
}
className=
'text-black placeholder:text-gray-400 rounded-none py-2.5 px-2'
className=
"text-black placeholder:text-gray-400 rounded-none py-2.5 px-2"
onChange=
{
(
e
)
=>
setQuery
(
e
.
target
.
value
)
}
onChange=
{
(
e
)
=>
setQuery
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
'Enter'
)
{
if
(
e
.
key
===
"Enter"
)
{
onSearch
?.(
query
)
onSearch
?.(
query
)
;
}
}
}
}
}
}
/>
/>
</
div
>
</
div
>
<
div
className=
"flex flex-col gap-3"
>
<
div
className=
"flex flex-col gap-3"
>
{
categories
&&
categories
.
length
>
0
?
(
{
categories
?.
length
categories
.
slice
(
0
,
visibleCount
).
map
((
c
)
=>
(
?
categories
.
slice
(
0
,
visibleCount
).
map
((
category
)
=>
(
<
label
key=
{
c
.
id
}
className=
"flex items-center gap-3"
>
<
label
key=
{
category
.
id
}
className=
"flex items-center gap-3"
>
<
Checkbox
checked=
{
!!
selected
[
c
.
id
]
}
onCheckedChange=
{
()
=>
toggle
(
c
.
id
)
}
/>
<
Checkbox
checked=
{
Boolean
(
selected
[
category
.
id
])
}
onCheckedChange=
{
()
=>
toggle
(
category
.
id
)
}
/>
<
div
className=
"flex justify-between w-full items-center"
>
<
div
className=
"flex justify-between w-full items-center"
>
<
span
className=
"text-sm"
>
{
c
.
title
}
</
span
>
<
span
className=
"text-sm"
>
{
category
.
title
}
</
span
>
<
span
className=
"text-sm text-gray-400"
>
(
{
c
.
count
}
)
</
span
>
<
span
className=
"text-sm text-gray-400"
>
(
{
category
.
count
}
)
</
span
>
</
div
>
</
div
>
</
label
>
</
label
>
))
))
)
:
null
}
:
null
}
<
div
className=
"mt-2 flex items-center gap-3"
>
<
div
className=
"mt-2 flex items-center gap-3"
>
{
(
categories
?.
length
??
0
)
>
visibleCount
&&
(
{
(
categories
?.
length
??
0
)
>
visibleCount
?
(
<
button
<
button
className=
"text-sm text-primary self-start"
className=
"text-sm text-primary self-start"
onClick=
{
()
=>
setVisibleCount
((
v
)
=>
v
+
5
)
}
onClick=
{
()
=>
setVisibleCount
((
current
)
=>
current
+
5
)
}
>
>
Xem thêm
Xem thêm
</
button
>
</
button
>
)
}
)
:
null
}
{
visibleCount
>
5
&&
(
{
visibleCount
>
5
?
(
<
button
<
button
className=
"text-sm text-gray-500 self-start"
className=
"text-sm text-gray-500 self-start"
onClick=
{
()
=>
setVisibleCount
(
5
)
}
onClick=
{
()
=>
setVisibleCount
(
5
)
}
>
>
Thu gọn
Thu gọn
</
button
>
</
button
>
)
}
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex gap-3"
>
<
div
className=
"flex gap-3"
>
<
Button
className=
"flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick=
{
()
=>
onSearch
?.(
query
)
}
>
<
Button
className=
"flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick=
{
()
=>
onSearch
?.(
query
)
}
>
Tìm kiếm
Tìm kiếm
</
Button
>
</
Button
>
<
Button
<
Button
className=
"flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
className=
"flex-1 rounded-none font-medium text-lg text-white hover:bg-muted-foreground hover:outline-1 outline-primary hover:text-primary"
onClick=
{
()
=>
{
onClick=
{
()
=>
{
setQuery
(
''
)
setQuery
(
""
);
// restore initial map
const
map
:
Record
<
string
,
boolean
>
=
{};
const
map
:
Record
<
string
,
boolean
>
=
{}
if
(
categories
?.
length
)
{
if
(
categories
&&
categories
.
length
)
{
categories
.
forEach
((
category
)
=>
{
categories
.
forEach
((
c
)
=>
(
map
[
c
.
id
]
=
false
))
map
[
category
.
id
]
=
false
;
});
}
}
setSelected
(
map
)
setSelected
(
map
);
setVisibleCount
(
5
)
setVisibleCount
(
5
);
onReset
?.()
onReset
?.();
}
}
}
}
>
>
Bỏ tìm
Bỏ tìm
</
Button
>
</
Button
>
</
div
>
</
div
>
</
aside
>
</
aside
>
)
)
;
}
}
;
export
default
ListFilter
export
default
ListFilter
;
src/components/shared/admin-auth-guard.tsx
View file @
82063bf1
"use client"
;
"use client"
;
import
{
useEffect
}
from
"react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
usePathname
,
useRouter
}
from
"next/navigation"
;
import
{
usePathname
,
useRouter
}
from
"next/navigation"
;
import
{
ensureValidAdminAccessToken
}
from
"@/lib/auth/admin-auth"
;
import
useAuthStore
from
"@/store/useAuthStore"
;
import
useAuthStore
from
"@/store/useAuthStore"
;
const
LOGIN_PATH
=
"/admin/login"
;
const
LOGIN_PATH
=
"/admin/login"
;
...
@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
...
@@ -71,20 +72,62 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const
hasHydrated
=
useAuthStore
((
state
)
=>
state
.
_hasHydrated
);
const
hasHydrated
=
useAuthStore
((
state
)
=>
state
.
_hasHydrated
);
const
isLoggedIn
=
useAuthStore
((
state
)
=>
state
.
appIsLoggedIn
);
const
isLoggedIn
=
useAuthStore
((
state
)
=>
state
.
appIsLoggedIn
);
const
accessToken
=
useAuthStore
((
state
)
=>
state
.
appAccessToken
);
const
accessToken
=
useAuthStore
((
state
)
=>
state
.
appAccessToken
);
const
accessTokenExpired
=
useAuthStore
((
state
)
=>
state
.
appAccessTokenExpired
);
const
refreshToken
=
useAuthStore
((
state
)
=>
state
.
appRefreshToken
);
const
isRefreshing
=
useAuthStore
((
state
)
=>
state
.
appIsRefreshing
);
const
[
isRestoringSession
,
setIsRestoringSession
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
hasHydrated
||
pathname
===
LOGIN_PATH
)
return
;
if
(
!
hasHydrated
||
pathname
===
LOGIN_PATH
)
return
;
if
(
!
isLoggedIn
||
!
accessToken
)
{
let
cancelled
=
false
;
const
restoreSession
=
async
()
=>
{
const
needsRefresh
=
Boolean
(
accessToken
&&
accessTokenExpired
&&
accessTokenExpired
<=
Date
.
now
()
&&
refreshToken
,
);
if
(
accessToken
&&
isLoggedIn
&&
!
needsRefresh
)
return
;
if
(
!
refreshToken
)
{
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
encodeURIComponent
(
pathname
)}
`
);
return
;
}
setIsRestoringSession
(
true
);
try
{
const
nextToken
=
await
ensureValidAdminAccessToken
();
if
(
!
nextToken
&&
!
cancelled
)
{
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
encodeURIComponent
(
pathname
)}
`
);
}
}
catch
{
if
(
!
cancelled
)
{
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
encodeURIComponent
(
pathname
)}
`
);
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
encodeURIComponent
(
pathname
)}
`
);
}
}
},
[
accessToken
,
hasHydrated
,
isLoggedIn
,
pathname
,
router
]);
}
finally
{
if
(
!
cancelled
)
{
setIsRestoringSession
(
false
);
}
}
};
void
restoreSession
();
return
()
=>
{
cancelled
=
true
;
};
},
[
accessToken
,
accessTokenExpired
,
hasHydrated
,
isLoggedIn
,
pathname
,
refreshToken
,
router
]);
if
(
pathname
===
LOGIN_PATH
)
{
if
(
pathname
===
LOGIN_PATH
)
{
return
<>
{
children
}
</>;
return
<>
{
children
}
</>;
}
}
if
(
!
hasHydrated
)
{
if
(
!
hasHydrated
||
isRefreshing
||
isRestoringSession
)
{
return
<
AdminAuthLoadingScreen
/>;
return
<
AdminAuthLoadingScreen
/>;
}
}
...
...
src/components/shared/admin-header.tsx
View file @
82063bf1
'use client'
;
'use client'
;
import
React
from
'react'
;
import
React
from
'react'
;
import
{
use
Router
,
use
Pathname
}
from
'next/navigation'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
LogOut
,
Menu
,
ShieldCheck
}
from
'lucide-react'
;
import
{
LogOut
,
Menu
,
ShieldCheck
}
from
'lucide-react'
;
import
{
toast
}
from
'sonner'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
deleteAuthLogout
}
from
'@/api/endpoints/
auth'
;
import
{
logoutAdmin
}
from
'@/lib/auth/admin-
auth'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
useAuthStore
from
'@/store/useAuthStore'
;
import
useAuthStore
from
'@/store/useAuthStore'
;
...
@@ -40,23 +39,30 @@ function getTitle(pathname: string): string {
...
@@ -40,23 +39,30 @@ function getTitle(pathname: string): string {
return
'Quản trị'
;
return
'Quản trị'
;
}
}
function
formatPrimaryRole
(
role
?:
string
)
{
if
(
!
role
)
return
currentUserRoleLabel
;
return
role
.
split
(
'_'
)
.
filter
(
Boolean
)
.
map
((
part
)
=>
part
.
charAt
(
0
).
toUpperCase
()
+
part
.
slice
(
1
))
.
join
(
' '
);
}
function
formatRoles
(
roles
?:
string
[])
{
if
(
!
roles
||
roles
.
length
===
0
)
return
currentUserRoleLabel
;
return
roles
.
map
((
role
)
=>
formatPrimaryRole
(
role
)).
join
(
', '
);
}
export
function
AdminHeader
()
{
export
function
AdminHeader
()
{
const
{
toggle
}
=
useSidebarStore
();
const
{
toggle
}
=
useSidebarStore
();
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
router
=
useRouter
();
const
title
=
getTitle
(
pathname
);
const
title
=
getTitle
(
pathname
);
const
resetStore
=
useAuthStore
((
state
)
=>
state
.
resetStore
);
const
currentUser
=
useAuthStore
((
state
)
=>
state
.
appUser
);
const
handleLogout
=
async
()
=>
{
const
handleLogout
=
async
()
=>
{
try
{
await
logoutAdmin
({
redirectToLogin
:
true
});
await
deleteAuthLogout
();
}
catch
{
// Ignore API logout failure and continue clearing local state.
}
finally
{
resetStore
();
toast
.
success
(
'Đã đăng xuất khỏi trang quản trị'
);
router
.
replace
(
'/admin/login'
);
}
};
};
return
(
return
(
...
@@ -78,7 +84,7 @@ export function AdminHeader() {
...
@@ -78,7 +84,7 @@ export function AdminHeader() {
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]"
>
<
div
className=
"flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]"
>
<
ShieldCheck
className=
"h-4 w-4 text-[#063e8e]"
/>
<
ShieldCheck
className=
"h-4 w-4 text-[#063e8e]"
/>
<
span
>
{
currentUserRoleLabel
}
</
span
>
<
span
>
{
formatRoles
(
currentUser
?.
roles
)
}
</
span
>
</
div
>
</
div
>
<
Button
<
Button
variant=
"outline"
variant=
"outline"
...
...
src/components/shared/editor-content/AppEditorContent.tsx
View file @
82063bf1
...
@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '
...
@@ -29,7 +29,7 @@ const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
if
(
tagName
===
'a'
)
{
if
(
tagName
===
'a'
)
{
// Tr
ả về children đã được xử lý (làm phẳng thẻ
<a>)
// Tr
? v? children d? du?c x? l? (l?m ph?ng th?
<a>)
return
<>
{
children
}
</>;
return
<>
{
children
}
</>;
}
}
...
...
src/lib/api/cms-admin.ts
View file @
82063bf1
...
@@ -2,7 +2,6 @@
...
@@ -2,7 +2,6 @@
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
{
categoryFallbackRows
}
from
"@/mockdata/categories"
;
import
{
categoryFallbackRows
}
from
"@/mockdata/categories"
;
import
useAuthStore
from
"@/store/useAuthStore"
;
export
type
CmsHeaderCategoryType
=
"category"
|
"page"
|
"news"
;
export
type
CmsHeaderCategoryType
=
"category"
|
"page"
|
"news"
;
...
@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => {
...
@@ -189,16 +188,11 @@ const readMessage = (payload: unknown) => {
const
authHeaders
=
(
withJson
=
true
)
=>
{
const
authHeaders
=
(
withJson
=
true
)
=>
{
const
headers
=
new
Headers
();
const
headers
=
new
Headers
();
const
token
=
useAuthStore
.
getState
().
appAccessToken
;
if
(
withJson
)
{
if
(
withJson
)
{
headers
.
set
(
"Content-Type"
,
"application/json"
);
headers
.
set
(
"Content-Type"
,
"application/json"
);
}
}
if
(
token
)
{
headers
.
set
(
"Authorization"
,
`Bearer
${
token
}
`
);
}
return
headers
;
return
headers
;
};
};
...
@@ -341,7 +335,9 @@ const transformPost = (
...
@@ -341,7 +335,9 @@ const transformPost = (
slug
:
post
.
slug
??
""
,
slug
:
post
.
slug
??
""
,
summary
:
post
.
summary
??
""
,
summary
:
post
.
summary
??
""
,
type
:
type
:
primaryCategoryType
===
"post"
||
primaryCategoryType
===
"page"
post
.
type
===
"page"
||
primaryCategoryType
===
"post"
||
primaryCategoryType
===
"page"
?
"baiviettrang"
?
"baiviettrang"
:
"tintuc"
,
:
"tintuc"
,
header_category_id
:
primaryCategory
?.
id
??
""
,
header_category_id
:
primaryCategory
?.
id
??
""
,
...
@@ -550,6 +546,29 @@ export async function fetchCmsTags() {
...
@@ -550,6 +546,29 @@ export async function fetchCmsTags() {
return
fetchAllTagsInternal
();
return
fetchAllTagsInternal
();
}
}
export
async
function
fetchCmsTagsPage
(
params
?:
{
page
?:
number
;
pageSize
?:
number
;
})
{
const
searchParams
=
new
URLSearchParams
({
page
:
String
(
params
?.
page
??
1
),
pageSize
:
String
(
params
?.
pageSize
??
10
),
sortField
:
"name"
,
sortOrder
:
"ASC"
,
});
const
result
=
await
cmsRequest
<
CmsPagedResult
<
CmsTagItem
>>
(
`/tag?
${
searchParams
.
toString
()}
`
,
);
return
{
items
:
result
.
rows
??
[],
total
:
result
.
count
??
0
,
page
:
result
.
page
??
params
?.
page
??
1
,
pageSize
:
result
.
pageSize
??
params
?.
pageSize
??
10
,
};
}
export
async
function
createCmsTag
(
input
:
{
name
:
string
;
slug
?:
string
})
{
export
async
function
createCmsTag
(
input
:
{
name
:
string
;
slug
?:
string
})
{
return
cmsRequest
<
CmsTagItem
>
(
"/tag"
,
{
return
cmsRequest
<
CmsTagItem
>
(
"/tag"
,
{
method
:
"POST"
,
method
:
"POST"
,
...
@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: {
...
@@ -723,6 +742,7 @@ export async function fetchCmsNewsItems(params?: {
pageSize
?:
number
;
pageSize
?:
number
;
sortField
?:
string
;
sortField
?:
string
;
sortOrder
?:
string
;
sortOrder
?:
string
;
filters
?:
string
;
})
{
})
{
const
queryParams
=
new
URLSearchParams
({
const
queryParams
=
new
URLSearchParams
({
page
:
String
(
params
?.
page
??
1
),
page
:
String
(
params
?.
page
??
1
),
...
@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: {
...
@@ -731,6 +751,10 @@ export async function fetchCmsNewsItems(params?: {
sortOrder
:
params
?.
sortOrder
??
"desc"
,
sortOrder
:
params
?.
sortOrder
??
"desc"
,
});
});
if
(
params
?.
filters
?.
trim
())
{
queryParams
.
set
(
"filters"
,
params
.
filters
.
trim
());
}
const
result
=
await
cmsRequest
<
CmsPagedResult
<
CmsRawPostItem
>>
(
const
result
=
await
cmsRequest
<
CmsPagedResult
<
CmsRawPostItem
>>
(
`/post?
${
queryParams
.
toString
()}
`
,
`/post?
${
queryParams
.
toString
()}
`
,
);
);
...
@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: {
...
@@ -757,6 +781,7 @@ export async function createCmsNewsItem(input: {
title
:
string
;
title
:
string
;
slug
:
string
;
slug
:
string
;
summary
:
string
;
summary
:
string
;
type
:
"tintuc"
|
"baiviettrang"
;
header_category_id
:
string
;
header_category_id
:
string
;
category_ids
:
string
[];
category_ids
:
string
[];
tag_ids
:
string
[];
tag_ids
:
string
[];
...
@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: {
...
@@ -776,6 +801,7 @@ export async function createCmsNewsItem(input: {
title
:
input
.
title
,
title
:
input
.
title
,
slug
:
input
.
slug
,
slug
:
input
.
slug
,
summary
:
input
.
summary
,
summary
:
input
.
summary
,
type
:
input
.
type
===
"baiviettrang"
?
"page"
:
"news"
,
external_link
:
input
.
slug
?
`/
${
input
.
slug
}
`
:
"/"
,
external_link
:
input
.
slug
?
`/
${
input
.
slug
}
`
:
"/"
,
content
:
input
.
summary
||
""
,
content
:
input
.
summary
||
""
,
category_ids
:
input
.
category_ids
,
category_ids
:
input
.
category_ids
,
...
@@ -816,6 +842,7 @@ export async function updateCmsNewsItem(
...
@@ -816,6 +842,7 @@ export async function updateCmsNewsItem(
title
:
string
;
title
:
string
;
slug
:
string
;
slug
:
string
;
summary
:
string
;
summary
:
string
;
type
:
"tintuc"
|
"baiviettrang"
;
header_category_id
:
string
;
header_category_id
:
string
;
category_ids
:
string
[];
category_ids
:
string
[];
tag_ids
:
string
[];
tag_ids
:
string
[];
...
@@ -836,6 +863,7 @@ export async function updateCmsNewsItem(
...
@@ -836,6 +863,7 @@ export async function updateCmsNewsItem(
title
:
input
.
title
,
title
:
input
.
title
,
slug
:
input
.
slug
,
slug
:
input
.
slug
,
summary
:
input
.
summary
,
summary
:
input
.
summary
,
type
:
input
.
type
===
"baiviettrang"
?
"page"
:
"news"
,
external_link
:
input
.
slug
?
`/
${
input
.
slug
}
`
:
"/"
,
external_link
:
input
.
slug
?
`/
${
input
.
slug
}
`
:
"/"
,
content
:
input
.
summary
||
""
,
content
:
input
.
summary
||
""
,
category_ids
:
input
.
category_ids
,
category_ids
:
input
.
category_ids
,
...
...
src/lib/auth/admin-auth.ts
0 → 100644
View file @
82063bf1
"use client"
;
import
{
toast
}
from
"sonner"
;
import
useAuthStore
,
{
type
AuthenticatedAdminSession
,
type
AuthenticatedAdminUser
,
}
from
"@/store/useAuthStore"
;
const
AUTH_BASE_URL
=
`
${
process
.
env
.
NEXT_PUBLIC_BACKEND_HOST
}
/api/v1.0/auth`
;
const
SESSION_EXPIRED_MESSAGE
=
"Phi?n dang nh?p d? h?t h?n. Vui l?ng dang nh?p l?i."
;
interface
AuthEnvelope
<
T
>
{
message
?:
string
|
null
;
message_en
?:
string
|
null
;
responseData
?:
T
;
data
?:
{
responseData
?:
T
;
};
}
interface
AuthErrorPayload
{
message
?:
string
|
null
;
message_en
?:
string
|
null
;
error
?:
{
message
?:
{
vi
?:
string
|
null
;
en
?:
string
|
null
;
};
};
}
interface
LoginResponseData
{
user
?:
Partial
<
AuthenticatedAdminUser
>
|
null
;
session
?:
Partial
<
AuthenticatedAdminSession
>
|
null
;
access_token
?:
string
|
null
;
refresh_token
?:
string
|
null
;
expires_in
?:
number
|
null
;
token_type
?:
string
|
null
;
}
interface
MeResponseData
extends
Partial
<
AuthenticatedAdminUser
>
{}
interface
RefreshResponseData
{
session
?:
Partial
<
AuthenticatedAdminSession
>
|
null
;
access_token
?:
string
|
null
;
refresh_token
?:
string
|
null
;
expires_in
?:
number
|
null
;
token_type
?:
string
|
null
;
}
interface
AuthRequestOptions
extends
RequestInit
{
skipAuthHeader
?:
boolean
;
}
type
AuthFailureReason
=
"missing_refresh_token"
|
"refresh_failed"
;
let
refreshPromise
:
Promise
<
string
|
null
>
|
null
=
null
;
let
forcedLogoutPromise
:
Promise
<
void
>
|
null
=
null
;
const
isObject
=
(
value
:
unknown
):
value
is
Record
<
string
,
unknown
>
=>
typeof
value
===
"object"
&&
value
!==
null
&&
!
Array
.
isArray
(
value
);
const
getEnvelopeData
=
<
T
>
(
payload
:
AuthEnvelope
<
T
>
)
=>
payload
.
responseData
??
payload
.
data
?.
responseData
;
const
getErrorMessage
=
(
payload
:
unknown
,
fallback
:
string
)
=>
{
if
(
!
isObject
(
payload
))
return
fallback
;
const
apiPayload
=
payload
as
AuthErrorPayload
;
return
(
apiPayload
.
error
?.
message
?.
vi
??
apiPayload
.
message
??
apiPayload
.
message_en
??
fallback
);
};
const
normalizeUser
=
(
user
?:
Partial
<
AuthenticatedAdminUser
>
|
null
):
AuthenticatedAdminUser
|
null
=>
{
if
(
!
user
?.
id
||
!
user
.
email
||
!
user
.
username
)
return
null
;
return
{
id
:
user
.
id
,
email
:
user
.
email
,
username
:
user
.
username
,
first_name
:
user
.
first_name
??
null
,
last_name
:
user
.
last_name
??
null
,
roles
:
Array
.
isArray
(
user
.
roles
)
?
user
.
roles
.
filter
((
value
):
value
is
string
=>
typeof
value
===
"string"
)
:
[],
permissions
:
Array
.
isArray
(
user
.
permissions
)
?
user
.
permissions
.
filter
((
value
):
value
is
string
=>
typeof
value
===
"string"
)
:
[],
status
:
user
.
status
??
null
,
last_login_at
:
user
.
last_login_at
??
null
,
};
};
const
normalizeSession
=
(
session
?:
Partial
<
AuthenticatedAdminSession
>
|
null
,
):
AuthenticatedAdminSession
|
null
=>
{
if
(
!
session
)
return
null
;
return
{
id
:
typeof
session
.
id
===
"string"
?
session
.
id
:
null
,
expires_at
:
typeof
session
.
expires_at
===
"string"
?
session
.
expires_at
:
null
,
refresh_expires_at
:
typeof
session
.
refresh_expires_at
===
"string"
?
session
.
refresh_expires_at
:
null
,
};
};
async
function
requestAuth
<
T
>
(
path
:
string
,
init
?:
AuthRequestOptions
,
):
Promise
<
T
>
{
const
headers
=
new
Headers
(
init
?.
headers
);
headers
.
set
(
"Content-Type"
,
"application/json"
);
if
(
!
init
?.
skipAuthHeader
)
{
const
token
=
useAuthStore
.
getState
().
appAccessToken
;
if
(
token
&&
!
headers
.
has
(
"Authorization"
))
{
headers
.
set
(
"Authorization"
,
`Bearer
${
token
}
`
);
}
}
const
response
=
await
fetch
(
`
${
AUTH_BASE_URL
}${
path
}
`
,
{
...
init
,
credentials
:
"include"
,
headers
,
});
const
data
=
(
await
response
.
json
().
catch
(()
=>
({})))
as
AuthEnvelope
<
T
>
&
AuthErrorPayload
;
if
(
!
response
.
ok
)
{
const
error
=
new
Error
(
getErrorMessage
(
data
,
"Yêu cầu xác thực thất bại."
))
as
Error
&
{
status
?:
number
;
payload
?:
unknown
;
};
error
.
status
=
response
.
status
;
error
.
payload
=
data
;
throw
error
;
}
return
getEnvelopeData
(
data
)
as
T
;
}
const
redirectToLogin
=
()
=>
{
if
(
typeof
window
===
"undefined"
)
return
;
const
currentPath
=
`
${
window
.
location
.
pathname
}${
window
.
location
.
search
}
`
;
const
redirect
=
currentPath
.
startsWith
(
"/admin"
)
&&
currentPath
!==
"/admin/login"
?
`?redirect=
${
encodeURIComponent
(
currentPath
)}
`
:
""
;
window
.
location
.
replace
(
`/admin/login
${
redirect
}
`
);
};
const
markSessionExpiredAndNotify
=
()
=>
{
const
store
=
useAuthStore
.
getState
();
if
(
!
store
.
appSessionExpiredNotified
)
{
store
.
markSessionExpiredNotified
(
true
);
toast
.
error
(
SESSION_EXPIRED_MESSAGE
);
}
};
export
async
function
loginAdmin
(
email
:
string
,
password
:
string
)
{
const
payload
=
await
requestAuth
<
LoginResponseData
>
(
"/login"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
email
,
password
,
}),
skipAuthHeader
:
true
,
});
if
(
!
payload
.
access_token
||
!
payload
.
refresh_token
||
!
payload
.
expires_in
)
{
throw
new
Error
(
"Thiếu dữ liệu phiên đăng nhập từ API."
);
}
const
me
=
await
requestAuth
<
MeResponseData
>
(
"/me"
,
{
method
:
"GET"
,
}).
catch
(()
=>
payload
.
user
??
null
);
const
normalizedUser
=
normalizeUser
(
me
??
payload
.
user
);
useAuthStore
.
getState
().
setAuthSession
({
accessToken
:
payload
.
access_token
,
refreshToken
:
payload
.
refresh_token
,
expiresIn
:
payload
.
expires_in
,
user
:
normalizedUser
,
session
:
normalizeSession
(
payload
.
session
),
});
useAuthStore
.
getState
().
setAppUser
(
normalizedUser
);
return
payload
;
}
export
async
function
logoutAdmin
(
options
?:
{
silent
?:
boolean
;
redirectToLogin
?:
boolean
;
reason
?:
AuthFailureReason
;
})
{
const
{
silent
=
false
,
redirectToLogin
:
shouldRedirect
=
true
,
reason
}
=
options
??
{};
if
(
forcedLogoutPromise
)
{
return
forcedLogoutPromise
;
}
forcedLogoutPromise
=
(
async
()
=>
{
const
store
=
useAuthStore
.
getState
();
const
refreshToken
=
store
.
appRefreshToken
;
try
{
await
requestAuth
(
"/logout"
,
{
method
:
"DELETE"
,
});
}
catch
{
// Ignore logout API failure and continue clearing local auth state.
}
finally
{
if
(
reason
===
"refresh_failed"
)
{
markSessionExpiredAndNotify
();
}
useAuthStore
.
getState
().
resetStore
();
if
(
reason
===
"refresh_failed"
)
{
useAuthStore
.
getState
().
markSessionExpiredNotified
(
true
);
}
else
if
(
!
silent
)
{
toast
.
success
(
"Đã đăng xuất khỏi trang quản trị"
);
}
if
(
shouldRedirect
)
{
redirectToLogin
();
}
}
})();
try
{
await
forcedLogoutPromise
;
}
finally
{
forcedLogoutPromise
=
null
;
}
}
export
async
function
refreshAdminAccessToken
()
{
if
(
refreshPromise
)
{
return
refreshPromise
;
}
refreshPromise
=
(
async
()
=>
{
const
store
=
useAuthStore
.
getState
();
const
refreshToken
=
store
.
appRefreshToken
;
if
(
!
refreshToken
)
{
await
logoutAdmin
({
silent
:
true
,
reason
:
"missing_refresh_token"
});
return
null
;
}
store
.
setAppRefreshing
(
true
);
try
{
const
payload
=
await
requestAuth
<
RefreshResponseData
>
(
"/refresh"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
refresh_token
:
refreshToken
,
}),
skipAuthHeader
:
true
,
});
if
(
!
payload
.
access_token
||
!
payload
.
expires_in
)
{
throw
new
Error
(
"Thiếu access token mới từ API."
);
}
useAuthStore
.
getState
().
updateAccessToken
({
accessToken
:
payload
.
access_token
,
expiresIn
:
payload
.
expires_in
,
refreshToken
:
payload
.
refresh_token
??
refreshToken
,
session
:
normalizeSession
(
payload
.
session
),
});
return
payload
.
access_token
;
}
catch
(
error
)
{
await
logoutAdmin
({
silent
:
true
,
reason
:
"refresh_failed"
});
throw
error
;
}
finally
{
useAuthStore
.
getState
().
setAppRefreshing
(
false
);
refreshPromise
=
null
;
}
})();
return
refreshPromise
;
}
export
async
function
ensureValidAdminAccessToken
()
{
const
store
=
useAuthStore
.
getState
();
if
(
!
store
.
appAccessToken
)
{
if
(
store
.
appRefreshToken
)
{
return
refreshAdminAccessToken
();
}
return
null
;
}
if
(
!
store
.
appAccessTokenExpired
||
store
.
appAccessTokenExpired
>
Date
.
now
())
{
return
store
.
appAccessToken
;
}
return
refreshAdminAccessToken
();
}
export
async
function
handleAdminUnauthorized
()
{
await
logoutAdmin
({
silent
:
true
,
reason
:
"refresh_failed"
});
}
export
const
adminSessionExpiredMessage
=
SESSION_EXPIRED_MESSAGE
;
src/mockdata/admin-news.ts
View file @
82063bf1
This diff is collapsed.
Click to expand it.
src/mockdata/categories.ts
View file @
82063bf1
...
@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [
...
@@ -257,7 +257,7 @@ export const categoryFallbackRows: Category[] = [
id
:
"142c9525-b206-4b87-8978-4b7048a46a3b"
,
id
:
"142c9525-b206-4b87-8978-4b7048a46a3b"
,
name
:
"Trang chủ"
,
name
:
"Trang chủ"
,
slug
:
"trang-chu"
,
slug
:
"trang-chu"
,
url
:
"/
trang-chu
"
,
url
:
"/"
,
sort_order
:
1
,
sort_order
:
1
,
created_at
:
"2026-05-14T04:54:26.127Z"
,
created_at
:
"2026-05-14T04:54:26.127Z"
,
created_by
:
null
,
created_by
:
null
,
...
...
src/store/useAuthStore.ts
View file @
82063bf1
import
{
create
}
from
'zustand'
import
{
create
}
from
"zustand"
;
import
{
devtools
,
persist
}
from
'zustand/middleware'
import
{
devtools
,
persist
}
from
"zustand/middleware"
;
export
interface
AuthStoreStateType
{
export
interface
AuthenticatedAdminUser
{
// States
id
:
string
;
appIsLoggedIn
:
boolean
email
:
string
;
appAccessToken
:
string
|
null
username
:
string
;
appAccessTokenExpired
:
number
|
null
first_name
:
string
|
null
;
appRefreshToken
:
string
|
null
last_name
:
string
|
null
;
appUserRemember
:
{
roles
:
string
[];
username
:
string
permissions
:
string
[];
password
:
string
status
:
string
|
null
;
remember
:
boolean
last_login_at
:
string
|
null
;
}
|
null
}
_hasHydrated
:
boolean
// Actions
export
interface
AuthenticatedAdminSession
{
setHasHydrated
:
(
state
:
AuthStoreStateType
)
=>
void
id
:
string
|
null
;
setAppIsLoggedIn
:
(
isLoggedIn
:
boolean
)
=>
void
expires_at
:
string
|
null
;
setAppToken
:
(
accessToken
:
string
,
accessTokenExpired
:
number
,
refreshToken
?:
string
)
=>
void
refresh_expires_at
:
string
|
null
;
removeAppToken
:
()
=>
void
}
setAppUserRemember
:
(
username
:
string
,
password
:
string
,
remember
:
boolean
)
=>
void
resetStore
:
()
=>
void
export
interface
AuthSessionPayload
{
accessToken
:
string
;
refreshToken
:
string
;
expiresIn
:
number
;
user
:
AuthenticatedAdminUser
|
null
;
session
:
AuthenticatedAdminSession
|
null
;
}
}
// Define store
export
interface
AuthRefreshPayload
{
const
useAuthStore
=
create
<
AuthStoreStateType
>
()(
accessToken
:
string
;
devtools
(
expiresIn
:
number
;
persist
(
refreshToken
?:
string
|
null
;
(
set
,
get
)
=>
({
session
?:
AuthenticatedAdminSession
|
null
;
// States
}
export
interface
AuthStoreStateType
{
appIsLoggedIn
:
boolean
;
appAccessToken
:
string
|
null
;
appAccessTokenExpired
:
number
|
null
;
appRefreshToken
:
string
|
null
;
appSession
:
AuthenticatedAdminSession
|
null
;
appUser
:
AuthenticatedAdminUser
|
null
;
appIsRefreshing
:
boolean
;
appSessionExpiredNotified
:
boolean
;
appUserRemember
:
{
username
:
string
;
password
:
string
;
remember
:
boolean
;
}
|
null
;
_hasHydrated
:
boolean
;
setHasHydrated
:
(
state
:
AuthStoreStateType
)
=>
void
;
setAppIsLoggedIn
:
(
isLoggedIn
:
boolean
)
=>
void
;
setAuthSession
:
(
payload
:
AuthSessionPayload
)
=>
void
;
updateAccessToken
:
(
payload
:
AuthRefreshPayload
)
=>
void
;
setAppUser
:
(
user
:
AuthenticatedAdminUser
|
null
)
=>
void
;
setAppToken
:
(
accessToken
:
string
,
accessTokenExpired
:
number
,
refreshToken
?:
string
)
=>
void
;
setAppRefreshing
:
(
isRefreshing
:
boolean
)
=>
void
;
markSessionExpiredNotified
:
(
notified
:
boolean
)
=>
void
;
removeAppToken
:
()
=>
void
;
setAppUserRemember
:
(
username
:
string
,
password
:
string
,
remember
:
boolean
)
=>
void
;
resetStore
:
()
=>
void
;
}
const
getAccessTokenExpiredAt
=
(
expiresIn
:
number
)
=>
Date
.
now
()
+
Math
.
max
(
expiresIn
-
300
,
0
)
*
1000
;
const
baseState
=
{
appIsLoggedIn
:
false
,
appIsLoggedIn
:
false
,
appAccessToken
:
null
,
appAccessToken
:
null
,
appAccessTokenExpired
:
null
,
appAccessTokenExpired
:
null
,
appRefreshToken
:
null
,
appRefreshToken
:
null
,
appSession
:
null
,
appUser
:
null
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
appUserRemember
:
null
,
appUserRemember
:
null
,
_hasHydrated
:
false
,
};
// Methods
const
clearSessionState
=
{
setAppIsLoggedIn
:
(
isLoggedIn
:
boolean
)
=>
set
(()
=>
({
appIsLoggedIn
:
isLoggedIn
})),
appIsLoggedIn
:
false
,
appAccessToken
:
null
,
appAccessTokenExpired
:
null
,
appRefreshToken
:
null
,
appSession
:
null
,
appUser
:
null
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
};
const
useAuthStore
=
create
<
AuthStoreStateType
>
()(
devtools
(
persist
(
(
set
,
get
)
=>
({
...
baseState
,
setHasHydrated
:
(
state
:
AuthStoreStateType
)
=>
set
(()
=>
({
_hasHydrated
:
state
!=
undefined
,
})),
setAppIsLoggedIn
:
(
isLoggedIn
:
boolean
)
=>
set
(()
=>
({
appIsLoggedIn
:
isLoggedIn
,
})),
setAuthSession
:
({
accessToken
,
refreshToken
,
expiresIn
,
user
,
session
})
=>
set
(()
=>
({
appIsLoggedIn
:
true
,
appAccessToken
:
accessToken
,
appAccessTokenExpired
:
getAccessTokenExpiredAt
(
expiresIn
),
appRefreshToken
:
refreshToken
,
appSession
:
session
,
appUser
:
user
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
})),
updateAccessToken
:
({
accessToken
,
expiresIn
,
refreshToken
,
session
})
=>
set
(()
=>
({
appIsLoggedIn
:
true
,
appAccessToken
:
accessToken
,
appAccessTokenExpired
:
getAccessTokenExpiredAt
(
expiresIn
),
appRefreshToken
:
refreshToken
??
get
().
appRefreshToken
,
appSession
:
session
??
get
().
appSession
,
appIsRefreshing
:
false
,
})),
setAppUser
:
(
user
:
AuthenticatedAdminUser
|
null
)
=>
set
(()
=>
({
appUser
:
user
,
})),
setAppToken
:
(
accessToken
:
string
,
accessTokenExpired
:
number
,
refreshToken
?:
string
)
=>
setAppToken
:
(
accessToken
:
string
,
accessTokenExpired
:
number
,
refreshToken
?:
string
)
=>
set
(()
=>
({
set
(()
=>
({
appIsLoggedIn
:
true
,
appIsLoggedIn
:
true
,
appAccessToken
:
accessToken
,
appAccessToken
:
accessToken
,
appAccessTokenExpired
:
Date
.
now
()
+
Math
.
max
(
accessTokenExpired
-
300
,
0
)
*
1000
,
appAccessTokenExpired
:
getAccessTokenExpiredAt
(
accessTokenExpired
),
appRefreshToken
:
refreshToken
??
get
().
appRefreshToken
appRefreshToken
:
refreshToken
??
get
().
appRefreshToken
,
appIsRefreshing
:
false
,
})),
setAppRefreshing
:
(
isRefreshing
:
boolean
)
=>
set
(()
=>
({
appIsRefreshing
:
isRefreshing
,
})),
markSessionExpiredNotified
:
(
notified
:
boolean
)
=>
set
(()
=>
({
appSessionExpiredNotified
:
notified
,
})),
})),
removeAppToken
:
()
=>
{
removeAppToken
:
()
=>
{
set
(()
=>
({
set
(()
=>
({
appIsLoggedIn
:
false
,
...
clearSessionState
,
appAccessToken
:
null
,
appUserRemember
:
get
().
appUserRemember
,
appAccessTokenExpired
:
null
,
_hasHydrated
:
get
().
_hasHydrated
,
appRefreshToken
:
null
}));
}))
},
},
setAppUserRemember
:
(
username
,
password
,
remember
)
=>
setAppUserRemember
:
(
username
,
password
,
remember
)
=>
set
(()
=>
({
set
(()
=>
({
appUserRemember
:
{
appUserRemember
:
{
username
,
username
,
password
,
password
,
remember
remember
,
}
}
,
})),
})),
resetStore
:
()
=>
{
resetStore
:
()
=>
{
// Clear in-memory state
const
rememberedUser
=
get
().
appUserRemember
;
set
(()
=>
({
set
(()
=>
({
appIsLoggedIn
:
false
,
...
clearSessionState
,
appAccessToken
:
null
,
appUserRemember
:
rememberedUser
,
appAccessTokenExpired
:
null
,
_hasHydrated
:
true
,
appRefreshToken
:
null
,
}));
appUserRemember
:
null
,
_hasHydrated
:
true
}))
// Remove persisted storage
try
{
try
{
localStorage
.
removeItem
(
'app-auth-storage'
)
localStorage
.
removeItem
(
"app-auth-storage"
);
}
catch
{
}
catch
{
// ignore
// ignore
}
}
},
},
_hasHydrated
:
false
,
setHasHydrated
:
(
state
:
AuthStoreStateType
)
=>
set
(()
=>
({
_hasHydrated
:
state
!=
undefined
}))
}),
}),
{
{
name
:
'app-auth-storage'
,
name
:
"app-auth-storage"
,
partialize
:
(
state
)
=>
({
appIsLoggedIn
:
state
.
appIsLoggedIn
,
appAccessToken
:
state
.
appAccessToken
,
appAccessTokenExpired
:
state
.
appAccessTokenExpired
,
appRefreshToken
:
state
.
appRefreshToken
,
appSession
:
state
.
appSession
,
appUser
:
state
.
appUser
,
appSessionExpiredNotified
:
state
.
appSessionExpiredNotified
,
appUserRemember
:
state
.
appUserRemember
,
}),
onRehydrateStorage
:
()
=>
{
onRehydrateStorage
:
()
=>
{
return
(
state
:
AuthStoreStateType
|
undefined
,
error
:
unknown
)
=>
{
return
(
state
:
AuthStoreStateType
|
undefined
,
error
:
unknown
)
=>
{
if
(
error
||
state
==
undefined
)
return
if
(
error
||
state
==
undefined
)
return
;
state
.
setHasHydrated
(
state
);
state
.
setHasHydrated
(
state
)
};
return
},
}
},
}
),
}
),
)
);
)
)
export
default
useAuthStore
export
default
useAuthStore
;
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