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
91a2bc40
Commit
91a2bc40
authored
May 21, 2026
by
Lê Đức Huy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement header, admin views, sidebar, and core api query configurations
parent
1334e92b
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
1097 additions
and
392 deletions
+1097
-392
query-client.ts
src/api/config/query-client.ts
+34
-32
index.tsx
src/app/(main)/(home)/components/banner/index.tsx
+81
-23
footer.tsx
src/app/(main)/_lib/layout/footer.tsx
+172
-20
header.tsx
src/app/(main)/_lib/layout/header.tsx
+157
-47
page.tsx
src/app/admin/base-config/page.tsx
+390
-165
page.tsx
src/app/admin/login/page.tsx
+161
-52
admin-sidebar.tsx
src/components/shared/admin-sidebar.tsx
+102
-53
No files found.
src/api/config/query-client.ts
View file @
91a2bc40
// Core
// Core
import
{
AxiosError
,
isAxiosError
}
from
'axios'
import
{
AxiosError
,
isAxiosError
}
from
'axios'
import
{
QueryClient
}
from
'@tanstack/react-query'
import
{
QueryClient
}
from
'@tanstack/react-query'
// App
// App
// import router from '@/router'
// import router from '@/router'
import
{
handleAdminUnauthorized
}
from
'@/lib/auth/admin-auth'
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'
// Constants
// Constants
const
RETRY_COUNT
=
3
const
RETRY_COUNT
=
3
const
EXPIRED_TOKEN_ERROR
=
401
const
EXPIRED_TOKEN_ERROR
=
401
const
DENIED_PERMISSION_ERROR
=
403
const
DENIED_PERMISSION_ERROR
=
403
const
INTERNAL_SERVER_ERROR
=
500
const
INTERNAL_SERVER_ERROR
=
500
const
API_QUERY_STALE_TIME
=
2
*
60
*
1000
const
API_QUERY_STALE_TIME
=
2
*
60
*
1000
const
API_QUERY_GC_TIME
=
10
*
60
*
1000
const
API_QUERY_GC_TIME
=
10
*
60
*
1000
// Utils
// Utils
// Handle check base retry logical
// Handle check base retry logical
...
@@ -27,8 +27,10 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
...
@@ -27,8 +27,10 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
// Expired token error
// Expired token error
if
(
error
.
response
?.
status
===
EXPIRED_TOKEN_ERROR
)
{
if
(
error
.
response
?.
status
===
EXPIRED_TOKEN_ERROR
)
{
handleUnAuthorizationError
()
if
(
typeof
window
!==
"undefined"
&&
window
.
location
.
pathname
.
startsWith
(
"/admin"
))
{
return
false
handleUnAuthorizationError
();
}
return
false
;
}
}
// Denied permission error
// Denied permission error
...
@@ -42,15 +44,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
...
@@ -42,15 +44,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
}
}
// Handle un authorization error
// Handle un authorization error
const
handleUnAuthorizationError
=
()
=>
{
const
handleUnAuthorizationError
=
()
=>
{
void
handleAdminUnauthorized
()
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('')
}
}
// Handle delay value
// Handle delay value
const
handleDelayRetry
=
(
failureCount
:
number
)
=>
failureCount
*
1000
+
Math
.
random
()
*
1000
const
handleDelayRetry
=
(
failureCount
:
number
)
=>
failureCount
*
1000
+
Math
.
random
()
*
1000
...
@@ -58,13 +60,13 @@ const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.ra
...
@@ -58,13 +60,13 @@ const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.ra
// Query client
// Query client
export
const
queryClient
=
new
QueryClient
({
export
const
queryClient
=
new
QueryClient
({
defaultOptions
:
{
defaultOptions
:
{
queries
:
{
queries
:
{
staleTime
:
API_QUERY_STALE_TIME
,
staleTime
:
API_QUERY_STALE_TIME
,
gcTime
:
API_QUERY_GC_TIME
,
gcTime
:
API_QUERY_GC_TIME
,
refetchOnWindowFocus
:
false
,
refetchOnWindowFocus
:
false
,
refetchOnMount
:
false
,
refetchOnMount
:
false
,
refetchOnReconnect
:
false
,
refetchOnReconnect
:
false
,
placeholderData
:
(
previousData
:
unknown
)
=>
previousData
,
placeholderData
:
(
previousData
:
unknown
)
=>
previousData
,
retry
(
failureCount
,
error
)
{
retry
(
failureCount
,
error
)
{
if
(
!
handleCheckBaseRetryLogical
(
failureCount
,
error
))
return
false
if
(
!
handleCheckBaseRetryLogical
(
failureCount
,
error
))
return
false
...
...
src/app/(main)/(home)/components/banner/index.tsx
View file @
91a2bc40
'use client'
;
"use client"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
import
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
...
@@ -7,40 +7,98 @@ import { Swiper as SwiperType } from "swiper/types";
...
@@ -7,40 +7,98 @@ import { Swiper as SwiperType } from "swiper/types";
import
{
useRef
}
from
"react"
;
import
{
useRef
}
from
"react"
;
import
"swiper/css"
;
import
"swiper/css"
;
import
{
useGetBanner
}
from
"@/api/endpoints/banner"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
fetchCmsFileById
,
resolveCmsFileUrl
}
from
"@/lib/api/files"
;
import
{
Skeleton
}
from
"@/components/ui/skeleton"
;
type
ApiEnvelope
<
T
>
=
{
responseData
?:
T
;
data
?:
{
responseData
?:
T
;
};
};
const
getEnvelopeData
=
<
T
,
>
(payload?: ApiEnvelope
<
T
>
) =
>
payload?.responseData ?? payload?.data?.responseData;
function BannerSlideItem(
{
fileId
,
alt
}
:
{
fileId
:
string
;
alt
:
string
}
)
{
const
{
data
:
file
,
isPending
}
=
useQuery
({
queryKey
:
[
"file"
,
fileId
],
queryFn
:
()
=>
fetchCmsFileById
(
fileId
),
enabled
:
!!
fileId
,
});
if
(
isPending
)
{
return
(
<
Skeleton
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px]"
/>
);
}
const
url
=
file
?
resolveCmsFileUrl
(
file
.
path
)
:
"/img-error.png"
;
return
(
<
ImageNext
src=
{
url
}
alt=
{
alt
}
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
);
}
const Banner = () =
>
{
const Banner = () =
>
{
const
swiperRef
=
useRef
<
SwiperType
|
null
>
(
null
);
const
swiperRef
=
useRef
<
SwiperType
|
null
>
(
null
);
const
{
data
:
bannerData
}
=
useGetBanner
({
filters
:
"status@=ACTIVE"
,
sortField
:
"display_order"
,
sortOrder
:
"asc"
,
});
const
pageData
=
getEnvelopeData
<
{
rows
?:
any
[]
}
>
(
bannerData
);
const
rows
=
pageData
?.
rows
??
[];
if
(
!
rows
||
rows
.
length
===
0
)
{
return
(
<
div
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-slate-100 flex items-center justify-center"
>
<
Skeleton
className=
"w-full h-full"
/>
</
div
>
);
}
return
(
return
(
<
Swiper
<
Swiper
modules=
{
[
Autoplay
]
}
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
loop
loop
=
{
rows
.
length
>
1
}
slidesPerView=
{
1
}
slidesPerView=
{
1
}
onSwiper=
{
(
s
)
=>
(
swiperRef
.
current
=
s
)
}
onSwiper=
{
(
s
)
=>
(
swiperRef
.
current
=
s
)
}
className=
"w-full overflow-hidden"
className=
"w-full overflow-hidden"
>
>
<
SwiperSlide
>
{
rows
.
map
((
row
:
any
)
=>
(
<
ImageNext
<
SwiperSlide
key=
{
row
.
id
}
>
src=
"https://vcci-hcm.org.vn/wp-content/uploads/2025/10/1.1.-Hero-Banner-CEO-2025-Bi-Sai-Nam-2025-Nhe-2560x720-Px.jpg.webp"
{
row
.
file_id
?
(
alt=
"Banner"
<
BannerSlideItem
width=
{
2560
}
fileId=
{
row
.
file_id
}
height=
{
720
}
alt=
{
row
.
banner_name
||
"Banner"
}
sizes=
"100vw"
/>
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
)
:
(
/>
<
ImageNext
</
SwiperSlide
>
src=
"/img-error.png"
<
SwiperSlide
>
alt=
{
row
.
banner_name
||
"Banner"
}
<
ImageNext
width=
{
2560
}
src=
"https://vcci-hcm.org.vn/wp-content/uploads/2022/07/Landscape-HCM_3-01.png"
height=
{
720
}
alt=
"Banner"
sizes=
"100vw"
width=
{
2560
}
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
height=
{
720
}
/>
sizes=
"100vw"
)
}
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
</
SwiperSlide
>
/>
))
}
</
SwiperSlide
>
</
Swiper
>
</
Swiper
>
);
);
}
}
;
export default Banner;
export default Banner;
src/app/(main)/_lib/layout/footer.tsx
View file @
91a2bc40
...
@@ -13,18 +13,61 @@ import {
...
@@ -13,18 +13,61 @@ import {
Youtube
,
Youtube
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
subscribeNewsletterEmail
}
from
"@/lib/api/newsletter-subscriptions"
;
import
{
subscribeNewsletterEmail
}
from
"@/lib/api/newsletter-subscriptions"
;
import
{
getSiteInformation
}
from
"@/api/endpoints/site-information"
;
import
type
{
SiteInformationData
}
from
"@/api/models"
;
const
socialLinks
=
[
type
ApiEnvelope
<
T
>
=
{
{
icon
:
<
Facebook
className=
"h-5 w-5"
/>,
link
:
"https://www.facebook.com/VCCIHCMC/"
},
responseData
?:
T
;
{
icon
:
<
Twitter
className=
"h-5 w-5"
/>,
link
:
"https://twitter.com/VCCI_HCM"
},
data
?:
{
{
icon
:
<
Youtube
className=
"h-5 w-5"
/>,
link
:
"https://www.youtube.com/user/VCCIHCMC"
},
responseData
?:
T
;
};
};
type
SocialItem
=
{
key
:
string
;
url
:
string
;
icon
:
React
.
ReactNode
;
};
const
fallbackSocials
:
SocialItem
[]
=
[
{
key
:
"facebook"
,
url
:
"https://www.facebook.com/VCCIHCMC/"
,
icon
:
<
Facebook
className=
"h-5 w-5"
/>,
},
{
key
:
"twitter"
,
url
:
"https://twitter.com/VCCI_HCM"
,
icon
:
<
Twitter
className=
"h-5 w-5"
/>,
},
{
{
key
:
"youtube"
,
url
:
"https://www.youtube.com/user/VCCIHCMC"
,
icon
:
<
Youtube
className=
"h-5 w-5"
/>,
},
{
key
:
"linkedin"
,
url
:
"https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym"
,
icon
:
<
Linkedin
className=
"h-5 w-5"
/>,
icon
:
<
Linkedin
className=
"h-5 w-5"
/>,
link
:
"https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym"
,
},
},
];
];
const
getEnvelopeData
=
<
T
,
>
(payload?: ApiEnvelope
<
T
>
| null) =
>
payload?.responseData ?? payload?.data?.responseData;
const getSocialIcon = (key: string): React.ReactNode =
>
{
const
normalized
=
key
.
toLowerCase
();
if
(
normalized
.
includes
(
"facebook"
))
return
<
Facebook
className=
"h-5 w-5"
/>;
if
(
normalized
.
includes
(
"twitter"
)
||
normalized
===
"x"
)
{
return
<
Twitter
className=
"h-5 w-5"
/>;
}
if
(
normalized
.
includes
(
"youtube"
))
return
<
Youtube
className=
"h-5 w-5"
/>;
if
(
normalized
.
includes
(
"linkedin"
))
return
<
Linkedin
className=
"h-5 w-5"
/>;
return
null
;
}
;
const quickLinks = [
const quickLinks = [
{
label
:
"Giới thiệu"
,
href
:
"/gioi-thieu"
}
,
{
label
:
"Giới thiệu"
,
href
:
"/gioi-thieu"
}
,
{
label
:
"Hội viên"
,
href
:
"/danh-ba-hoi-vien"
}
,
{
label
:
"Hội viên"
,
href
:
"/danh-ba-hoi-vien"
}
,
...
@@ -32,7 +75,8 @@ const quickLinks = [
...
@@ -32,7 +75,8 @@ const quickLinks = [
{
label
:
"Xúc tiến Thương mại"
,
href
:
"/xuc-tien-thuong-mai/co-hoi/"
}
,
{
label
:
"Xúc tiến Thương mại"
,
href
:
"/xuc-tien-thuong-mai/co-hoi/"
}
,
];
];
const
isValidEmail
=
(
value
:
string
)
=>
/^
[^\s
@
]
+@
[^\s
@
]
+
\.[^\s
@
]
+$/
.
test
(
value
);
const isValidEmail = (value: string) =
>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
function Footer()
{
function Footer()
{
const
[
email
,
setEmail
]
=
useState
(
""
);
const
[
email
,
setEmail
]
=
useState
(
""
);
...
@@ -42,6 +86,67 @@ function Footer() {
...
@@ -42,6 +86,67 @@ function Footer() {
const
[
message
,
setMessage
]
=
useState
(
""
);
const
[
message
,
setMessage
]
=
useState
(
""
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
{
data
:
siteInformationResponse
}
=
useQuery
<
ApiEnvelope
<
SiteInformationData
>
|
null
>
({
queryKey
:
[
"site-information"
],
queryFn
:
()
=>
getSiteInformation
().
catch
(()
=>
null
),
staleTime
:
5
*
60
*
1000
,
});
const
siteInformation
=
getEnvelopeData
<
SiteInformationData
>
(
siteInformationResponse
,
);
const
primaryBranch
=
siteInformation
?.
branches
?.
find
((
branch
)
=>
branch
?.
is_active
)
??
siteInformation
?.
branches
?.[
0
]
??
null
;
const
branches
=
(
siteInformation
?.
branches
??
[])
.
filter
((
branch
)
=>
branch
?.
is_active
??
true
)
.
sort
((
left
,
right
)
=>
(
left
.
sort_order
??
0
)
-
(
right
.
sort_order
??
0
));
const
extraBranches
=
branches
.
filter
(
(
branch
)
=>
branch
?.
id
&&
branch
?.
id
!==
primaryBranch
?.
id
,
);
const
contactInfo
=
{
name
:
siteInformation
?.
website_name
??
"LIÊN ĐOÀN THƯƠNG MẠI & CÔNG NGHIỆP VIỆT NAM - CHI NHÁNH KHU VỰC THÀNH PHỐ HỒ CHÍ MINH"
,
address
:
siteInformation
?.
address
??
primaryBranch
?.
address
??
"171 Võ Thị Sáu, Phường Xuân Hòa, TP. HCM"
,
telephone
:
siteInformation
?.
telephone
??
primaryBranch
?.
telephone
??
primaryBranch
?.
hotline
??
"+84 28 3932 6598"
,
fax
:
primaryBranch
?.
fax
??
"+84 28 3932 5472"
,
email
:
siteInformation
?.
email
??
primaryBranch
?.
email
??
"info@vcci-hcm.org.vn"
,
};
const
socialLinks
=
(()
=>
{
const
socials
=
siteInformation
?.
socials
??
siteInformation
?.
link_socials
??
[];
const
active
=
socials
.
filter
((
item
)
=>
item
?.
is_active
&&
item
?.
url
)
.
sort
((
left
,
right
)
=>
(
left
.
sort_order
??
0
)
-
(
right
.
sort_order
??
0
))
.
map
((
item
)
=>
{
const
key
=
item
.
icon_key
||
item
.
platform
||
item
.
label
||
""
;
const
icon
=
getSocialIcon
(
key
);
if
(
!
icon
||
!
item
.
url
)
return
null
;
return
{
key
:
item
.
id
||
key
,
url
:
item
.
url
,
icon
,
}
as
SocialItem
;
})
.
filter
((
item
):
item
is
SocialItem
=>
item
!==
null
);
return
active
.
length
?
active
:
fallbackSocials
;
})();
const
handleSubmit
=
async
()
=>
{
const
handleSubmit
=
async
()
=>
{
const
trimmedEmail
=
email
.
trim
();
const
trimmedEmail
=
email
.
trim
();
let
hasError
=
false
;
let
hasError
=
false
;
...
@@ -75,7 +180,11 @@ function Footer() {
...
@@ -75,7 +180,11 @@ function Footer() {
setAccepted
(
false
);
setAccepted
(
false
);
setMessage
(
"Đăng ký nhận thông tin thành công."
);
setMessage
(
"Đăng ký nhận thông tin thành công."
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setMessage
(
error
instanceof
Error
?
error
.
message
:
"Không thể đăng ký nhận thông tin."
);
setMessage
(
error
instanceof
Error
?
error
.
message
:
"Không thể đăng ký nhận thông tin."
,
);
}
finally
{
}
finally
{
setSubmitting
(
false
);
setSubmitting
(
false
);
}
}
...
@@ -144,48 +253,91 @@ function Footer() {
...
@@ -144,48 +253,91 @@ function Footer() {
</
div
>
</
div
>
<
div
>
<
div
>
<
h2
className=
"client-footer-title uppercase"
>
<
h2
className=
"client-footer-title uppercase"
>
Liên hệ
</
h2
>
Liên hệ
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-5 space-y-4"
>
<
div
className=
"mt-5 space-y-4"
>
<
p
className=
"max-w-[420px] text-[16px] font-semibold leading-[1.5] text-[#dce7ff]"
>
<
p
className=
"max-w-[420px] text-[16px] font-semibold leading-[1.5] text-[#dce7ff]"
>
LIÊN ĐOÀN THƯƠNG MẠI
&
CÔNG NGHIỆP VIỆT NAM - CHI NHÁNH KHU VỰC THÀNH PHỐ HỒ CHÍ MINH
{
contactInfo
.
name
}
</
p
>
</
p
>
<
div
className=
"space-y-2.5 text-[15px] text-[#c7d8ff]"
>
<
div
className=
"space-y-2.5 text-[15px] text-[#c7d8ff]"
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"flex items-start gap-3"
>
<
MapPin
className=
"mt-0.5 h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
MapPin
className=
"mt-0.5 h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
span
>
171 Võ Thị Sáu, Phường Xuân Hòa, TP. HCM
</
span
>
<
span
>
{
contactInfo
.
address
}
</
span
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex items-center gap-3"
>
<
Phone
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
Phone
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
span
>
+84 28 3932 6598
</
span
>
<
span
>
{
contactInfo
.
telephone
}
</
span
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex items-center gap-3"
>
<
Printer
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
Printer
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
span
>
+84 28 3932 5472
</
span
>
<
span
>
{
contactInfo
.
fax
}
</
span
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex items-center gap-3"
>
<
Mail
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
Mail
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
a
href=
"mailto:info@vcci-hcm.org.vn"
>
info@vcci-hcm.org.vn
</
a
>
<
a
href=
{
`mailto:${contactInfo.email}`
}
>
{
contactInfo
.
email
}
</
a
>
</
div
>
</
div
>
</
div
>
</
div
>
{
extraBranches
.
length
>
0
?
(
<
div
className=
"pt-4"
>
<
p
className=
"text-[14px] font-semibold uppercase text-[#dce7ff]"
>
Chi nhánh khác
</
p
>
<
div
className=
"mt-3 space-y-3 text-[14px] text-[#c7d8ff]"
>
{
extraBranches
.
map
((
branch
)
=>
(
<
div
key=
{
branch
.
id
}
className=
"space-y-1"
>
{
branch
.
branch_name
?
(
<
p
className=
"font-semibold text-[#dce7ff]"
>
{
branch
.
branch_name
}
</
p
>
)
:
null
}
{
branch
.
address
?
(
<
div
className=
"flex items-start gap-2"
>
<
MapPin
className=
"mt-0.5 h-3.5 w-3.5 shrink-0 text-[#f7b500]"
/>
<
span
>
{
branch
.
address
}
</
span
>
</
div
>
)
:
null
}
{
branch
.
telephone
||
branch
.
hotline
?
(
<
div
className=
"flex items-center gap-2"
>
<
Phone
className=
"h-3.5 w-3.5 shrink-0 text-[#f7b500]"
/>
<
span
>
{
branch
.
telephone
||
branch
.
hotline
}
</
span
>
</
div
>
)
:
null
}
{
branch
.
fax
?
(
<
div
className=
"flex items-center gap-2"
>
<
Printer
className=
"h-3.5 w-3.5 shrink-0 text-[#f7b500]"
/>
<
span
>
{
branch
.
fax
}
</
span
>
</
div
>
)
:
null
}
{
branch
.
email
?
(
<
div
className=
"flex items-center gap-2"
>
<
Mail
className=
"h-3.5 w-3.5 shrink-0 text-[#f7b500]"
/>
<
a
href=
{
`mailto:${branch.email}`
}
>
{
branch
.
email
}
</
a
>
</
div
>
)
:
null
}
</
div
>
))
}
</
div
>
</
div
>
)
:
null
}
</
div
>
</
div
>
</
div
>
</
div
>
<
div
>
<
div
>
<
h2
className=
"client-footer-title uppercase"
>
<
h2
className=
"client-footer-title uppercase"
>
Kết nối
</
h2
>
Kết nối
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-5 flex flex-wrap gap-3"
>
<
div
className=
"mt-5 flex flex-wrap gap-3"
>
{
socialLinks
.
map
((
item
)
=>
(
{
socialLinks
.
map
((
item
)
=>
(
<
a
<
a
key=
{
item
.
link
}
key=
{
item
.
key
}
href=
{
item
.
link
}
href=
{
item
.
url
}
target=
"_blank"
target=
"_blank"
rel=
"noreferrer"
rel=
"noreferrer"
className=
"flex h-11 w-11 items-center justify-center rounded-full bg-[#2a4ec4] text-white transition-colors hover:bg-[#3b60da]"
className=
"flex h-11 w-11 items-center justify-center rounded-full bg-[#2a4ec4] text-white transition-colors hover:bg-[#3b60da]"
...
...
src/app/(main)/_lib/layout/header.tsx
View file @
91a2bc40
...
@@ -7,8 +7,15 @@ import { useQuery } from "@tanstack/react-query";
...
@@ -7,8 +7,15 @@ import { useQuery } from "@tanstack/react-query";
import
Image
from
"next/image"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
{
getLogo
}
from
"@/api/endpoints/logo"
;
import
{
getSiteInformation
}
from
"@/api/endpoints/site-information"
;
import
type
{
Logo
}
from
"@/api/models/logo"
;
import
type
{
SiteInformationData
,
SiteInformationSocialLink
,
}
from
"@/api/models"
;
import
MenuItem
from
"@/components/base/menu-item"
;
import
MenuItem
from
"@/components/base/menu-item"
;
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
{
useCustomClient
as
customClient
}
from
"@/api/mutator/custom-client"
;
import
type
{
Category
}
from
"@/api/models/category"
;
import
type
{
Category
}
from
"@/api/models/category"
;
import
{
getCategoryFallbackResponse
}
from
"@/mockdata/categories"
;
import
{
getCategoryFallbackResponse
}
from
"@/mockdata/categories"
;
...
@@ -26,6 +33,67 @@ type CategoryListResponse = {
...
@@ -26,6 +33,67 @@ type CategoryListResponse = {
};
};
};
};
type
LogoListEnvelope
=
{
data
?:
{
responseData
?:
{
rows
?:
Logo
[];
};
};
};
type
ApiEnvelope
<
T
>
=
{
responseData
?:
T
;
data
?:
{
responseData
?:
T
;
};
};
type
SocialItem
=
{
key
:
string
;
url
:
string
;
icon
:
React
.
ReactNode
;
};
const
getEnvelopeData
=
<
T
,
>
(payload?: ApiEnvelope
<
T
>
| null) =
>
payload?.responseData ?? payload?.data?.responseData;
const fallbackSocials: SocialItem[] = [
{
key
:
"facebook"
,
url
:
"https://www.facebook.com/VCCIHCMC/"
,
icon
:
<
Facebook
size
=
{
12
}
fill
=
"currentColor"
/>
,
}
,
{
key
:
"twitter"
,
url
:
"https://twitter.com/VCCI_HCM"
,
icon
:
<
Twitter
size
=
{
12
}
fill
=
"currentColor"
/>
,
}
,
{
key
:
"youtube"
,
url
:
"https://www.youtube.com/user/VCCIHCMC"
,
icon
:
<
Youtube
size
=
{
12
}
fill
=
"currentColor"
/>
,
}
,
{
key
:
"linkedin"
,
url
:
"https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym"
,
icon
:
<
Linkedin
size
=
{
12
}
fill
=
"currentColor"
/>
,
}
,
];
const getSocialIcon = (key: string) =
>
{
const
normalized
=
key
.
toLowerCase
();
if
(
normalized
.
includes
(
"facebook"
))
return
<
Facebook
size=
{
12
}
fill=
"currentColor"
/>;
if
(
normalized
.
includes
(
"twitter"
)
||
normalized
===
"x"
)
{
return
<
Twitter
size=
{
12
}
fill=
"currentColor"
/>;
}
if
(
normalized
.
includes
(
"youtube"
))
return
<
Youtube
size=
{
12
}
fill=
"currentColor"
/>;
if
(
normalized
.
includes
(
"linkedin"
))
return
<
Linkedin
size=
{
12
}
fill=
"currentColor"
/>;
return
null
;
}
;
function normalizeCategoryUrl(url?: string | null)
{
function normalizeCategoryUrl(url?: string | null)
{
if
(
!
url
)
return
"#"
;
if
(
!
url
)
return
"#"
;
return
url
.
startsWith
(
"/"
)
?
url
:
`/${url}`
;
return
url
.
startsWith
(
"/"
)
?
url
:
`/${url}`
;
...
@@ -102,16 +170,63 @@ function Header() {
...
@@ -102,16 +170,63 @@ function Header() {
const
{
data
:
categoriesResponse
}
=
useQuery
({
const
{
data
:
categoriesResponse
}
=
useQuery
({
queryKey
:
[
"header-categories"
],
queryKey
:
[
"header-categories"
],
queryFn
:
()
=>
queryFn
:
()
=>
useC
ustomClient
<
CategoryListResponse
>
(
c
ustomClient
<
CategoryListResponse
>
(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC"
,
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC"
,
).
catch
(()
=>
getCategoryFallbackResponse
()),
).
catch
(()
=>
getCategoryFallbackResponse
()),
staleTime
:
5
*
60
*
1000
,
staleTime
:
5
*
60
*
1000
,
});
});
const
{
data
:
currentLogo
=
null
}
=
useQuery
({
queryKey
:
[
"header-logo"
],
queryFn
:
()
=>
getLogo
({
page
:
1
,
pageSize
:
1
,
sortField
:
"updated_at"
,
sortOrder
:
"desc"
,
}).
catch
(()
=>
null
),
select
:
(
response
)
=>
(
response
as
LogoListEnvelope
|
null
)?.
data
?.
responseData
?.
rows
?.[
0
]
??
null
,
staleTime
:
5
*
60
*
1000
,
});
const
{
data
:
siteInformationResponse
}
=
useQuery
<
ApiEnvelope
<
SiteInformationData
>
|
null
>
({
queryKey
:
[
"site-information"
],
queryFn
:
()
=>
getSiteInformation
().
catch
(()
=>
null
),
staleTime
:
5
*
60
*
1000
,
});
const
menuItems
=
useMemo
(
const
menuItems
=
useMemo
(
()
=>
buildHeaderMenuTree
(
categoriesResponse
?.
responseData
?.
rows
),
()
=>
buildHeaderMenuTree
(
categoriesResponse
?.
responseData
?.
rows
),
[
categoriesResponse
?.
responseData
?.
rows
],
[
categoriesResponse
?.
responseData
?.
rows
],
);
);
const
siteInformation
=
getEnvelopeData
<
SiteInformationData
>
(
siteInformationResponse
,
);
const
socialLinks
=
useMemo
(()
=>
{
const
socials
=
siteInformation
?.
socials
??
siteInformation
?.
link_socials
??
[];
const
active
=
socials
.
filter
((
item
)
=>
item
?.
is_active
&&
item
?.
url
)
.
sort
((
left
,
right
)
=>
(
left
.
sort_order
??
0
)
-
(
right
.
sort_order
??
0
))
.
map
((
item
):
SocialItem
|
null
=>
{
const
key
=
item
.
icon_key
||
item
.
platform
||
item
.
label
||
""
;
const
icon
=
getSocialIcon
(
key
);
if
(
!
icon
||
!
item
.
url
)
return
null
;
return
{
key
:
item
.
id
||
key
,
url
:
item
.
url
,
icon
,
};
})
.
filter
((
item
):
item
is
SocialItem
=>
Boolean
(
item
));
return
active
.
length
?
active
:
fallbackSocials
;
},
[
siteInformation
?.
socials
,
siteInformation
?.
link_socials
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
const
handleScroll
=
()
=>
{
const
handleScroll
=
()
=>
{
...
@@ -167,12 +282,13 @@ function Header() {
...
@@ -167,12 +282,13 @@ function Header() {
<
div
className=
"flex items-center gap-4"
>
<
div
className=
"flex items-center gap-4"
>
<
input
<
input
className=
"h-
[28px] w-[176px]
rounded-[4px] border border-[#3a57b4] bg-[#3554b7] px-3 text-[13px] text-white outline-none placeholder:text-[13px] placeholder:text-[#b5c4ff]"
className=
"h-
7 w-44
rounded-[4px] border border-[#3a57b4] bg-[#3554b7] px-3 text-[13px] text-white outline-none placeholder:text-[13px] placeholder:text-[#b5c4ff]"
type=
"text"
type=
"text"
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
encoded
=
encodeURIComponent
(
value
);
const
encoded
=
encodeURIComponent
(
value
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
}
}
...
@@ -180,50 +296,34 @@ function Header() {
...
@@ -180,50 +296,34 @@ function Header() {
/>
/>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
a
{
socialLinks
.
map
((
item
)
=>
(
href=
"https://www.facebook.com/VCCIHCMC/"
<
a
target=
"_blank"
key=
{
item
.
key
}
rel=
"noreferrer"
href=
{
item
.
url
}
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
target=
"_blank"
>
rel=
"noreferrer"
<
Facebook
size=
{
12
}
fill=
"currentColor"
/>
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
</
a
>
>
<
a
{
item
.
icon
}
href=
"https://twitter.com/VCCI_HCM"
</
a
>
target=
"_blank"
))
}
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<
Twitter
size=
{
12
}
fill=
"currentColor"
/>
</
a
>
<
a
href=
"https://www.youtube.com/user/VCCIHCMC"
target=
"_blank"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<
Youtube
size=
{
12
}
fill=
"currentColor"
/>
</
a
>
<
a
href=
"https://www.linkedin.com/company/vietnam-chamber-of-commerce-and-industry-ho-chi-minh-city-branch-vcci-hcm-?trk=biz-companies-cym"
target=
"_blank"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<
Linkedin
size=
{
12
}
fill=
"currentColor"
/>
</
a
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"border-b border-slate-200 bg-white"
>
<
div
className=
"border-b border-slate-200 bg-white"
>
<
div
className=
"mx-auto flex h-[80px] w-full max-w-[1460px] items-center justify-between gap-10 px-6 xl:px-8"
>
<
div
className=
"mx-auto flex h-20 w-full max-w-[1460px] items-center justify-between gap-10 px-6 xl:px-8"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
<
Image
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto w-[108px] object-contain"
className=
"h-auto w-[108px] object-contain"
src=
{
logo
}
src=
{
currentLogo
?.
logo_url
||
logo
}
alt=
"VCCI-HCM"
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
priority
/>
/>
</
Link
>
</
Link
>
...
@@ -248,7 +348,7 @@ function Header() {
...
@@ -248,7 +348,7 @@ function Header() {
</
div
>
</
div
>
</
nav
>
</
nav
>
</
div
>
</
div
>
<
button
<
button
onClick=
{
()
=>
setToggleMenu
((
prev
)
=>
!
prev
)
}
onClick=
{
()
=>
setToggleMenu
((
prev
)
=>
!
prev
)
}
className=
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 bg-white text-[#163b73] transition hover:bg-slate-50 lg:hidden"
className=
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 bg-white text-[#163b73] transition hover:bg-slate-50 lg:hidden"
...
@@ -260,7 +360,7 @@ function Header() {
...
@@ -260,7 +360,7 @@ function Header() {
</
div
>
</
div
>
<
div
<
div
className=
{
`fixed inset-0 z-
[60]
bg-white transition-all duration-300 lg:hidden ${
className=
{
`fixed inset-0 z-
60
bg-white transition-all duration-300 lg:hidden ${
toggleMenu
toggleMenu
? "pointer-events-auto translate-y-0 opacity-100"
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none -translate-y-2 opacity-0"
: "pointer-events-none -translate-y-2 opacity-0"
...
@@ -268,11 +368,17 @@ function Header() {
...
@@ -268,11 +368,17 @@ function Header() {
>
>
<
div
className=
"flex h-full flex-col overflow-hidden"
>
<
div
className=
"flex h-full flex-col overflow-hidden"
>
<
div
className=
"sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]"
>
<
div
className=
"sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Image
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto w-[108px] object-contain"
className=
"h-auto w-[108px] object-contain"
src=
{
logo
}
src=
{
currentLogo
?.
logo_url
||
logo
}
alt=
"VCCI-HCM"
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
priority
/>
/>
</
Link
>
</
Link
>
...
@@ -292,7 +398,8 @@ function Header() {
...
@@ -292,7 +398,8 @@ function Header() {
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
encoded
=
encodeURIComponent
(
value
);
const
encoded
=
encodeURIComponent
(
value
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
setToggleMenu
(
false
);
setToggleMenu
(
false
);
...
@@ -302,7 +409,10 @@ function Header() {
...
@@ -302,7 +409,10 @@ function Header() {
<
div
className=
"pb-6"
>
<
div
className=
"pb-6"
>
{
menuItems
.
map
((
category
)
=>
(
{
menuItems
.
map
((
category
)
=>
(
<
div
key=
{
category
.
id
}
className=
"border-t border-slate-100 first:border-t-0"
>
<
div
key=
{
category
.
id
}
className=
"border-t border-slate-100 first:border-t-0"
>
<
Link
<
Link
href=
{
category
.
url
||
"#"
}
href=
{
category
.
url
||
"#"
}
className=
"block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]"
className=
"block px-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]"
...
...
src/app/admin/base-config/page.tsx
View file @
91a2bc40
...
@@ -20,7 +20,13 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
...
@@ -20,7 +20,13 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
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
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
}
from
"@/components/ui/card"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
,
}
from
"@/components/ui/card"
;
import
{
import
{
Dialog
,
Dialog
,
DialogContent
,
DialogContent
,
...
@@ -43,11 +49,22 @@ import {
...
@@ -43,11 +49,22 @@ import {
postSiteInformationBranches
,
postSiteInformationBranches
,
putSiteInformation
,
putSiteInformation
,
}
from
"@/api/endpoints/site-information"
;
}
from
"@/api/endpoints/site-information"
;
import
{
deleteLogoId
,
postLogo
,
putLogoId
}
from
"@/api/endpoints/logo"
;
import
{
import
{
deleteBannerId
,
getBanner
,
postBanner
,
putBannerId
}
from
"@/api/endpoints/banner"
;
deleteLogoId
,
getLogo
,
postLogo
,
putLogoId
,
}
from
"@/api/endpoints/logo"
;
import
{
deleteBannerId
,
getBanner
,
postBanner
,
putBannerId
,
}
from
"@/api/endpoints/banner"
;
import
type
{
import
type
{
Banner
,
Banner
,
BannerMutate
,
BannerMutate
,
Logo
,
SiteInformationBranch
,
SiteInformationBranch
,
SiteInformationBranchMutate
,
SiteInformationBranchMutate
,
SiteInformationData
,
SiteInformationData
,
...
@@ -87,6 +104,10 @@ type LogoMediaItem = AdminMediaItem & {
...
@@ -87,6 +104,10 @@ type LogoMediaItem = AdminMediaItem & {
logoId
?:
string
;
logoId
?:
string
;
};
};
type
LogoListResponse
=
{
rows
?:
Logo
[];
};
type
PageEnvelope
<
T
>
=
{
type
PageEnvelope
<
T
>
=
{
rows
?:
T
[];
rows
?:
T
[];
count
?:
number
;
count
?:
number
;
...
@@ -112,7 +133,10 @@ function emptyItemForm(): ConfigItemForm {
...
@@ -112,7 +133,10 @@ function emptyItemForm(): ConfigItemForm {
};
};
}
}
function
resolveMediaItem
(
mediaMap
:
Map
<
string
,
AdminMediaItem
>
,
imageId
:
string
)
{
function
resolveMediaItem
(
mediaMap
:
Map
<
string
,
AdminMediaItem
>
,
imageId
:
string
,
)
{
return
mediaMap
.
get
(
imageId
)
??
null
;
return
mediaMap
.
get
(
imageId
)
??
null
;
}
}
...
@@ -121,7 +145,9 @@ function getEnvelopeData<T>(payload: unknown): T | undefined {
...
@@ -121,7 +145,9 @@ function getEnvelopeData<T>(payload: unknown): T | undefined {
return
root
.
responseData
??
root
.
data
?.
responseData
;
return
root
.
responseData
??
root
.
data
?.
responseData
;
}
}
function
mapApiBranchToConfig
(
branch
:
SiteInformationBranch
):
BaseConfigBranchItem
{
function
mapApiBranchToConfig
(
branch
:
SiteInformationBranch
,
):
BaseConfigBranchItem
{
return
{
return
{
id
:
branch
.
id
??
createBaseConfigItemId
(
"branch"
),
id
:
branch
.
id
??
createBaseConfigItemId
(
"branch"
),
branchName
:
branch
.
branch_name
??
""
,
branchName
:
branch
.
branch_name
??
""
,
...
@@ -146,12 +172,16 @@ function mapConfigBranchToApi(
...
@@ -146,12 +172,16 @@ function mapConfigBranchToApi(
email
:
branch
.
email
.
trim
()
||
null
,
email
:
branch
.
email
.
trim
()
||
null
,
fax
:
branch
.
fax
.
trim
()
||
null
,
fax
:
branch
.
fax
.
trim
()
||
null
,
googlemap_link
:
branch
.
mapsEmbedUrl
.
trim
()
||
null
,
googlemap_link
:
branch
.
mapsEmbedUrl
.
trim
()
||
null
,
sort_order
:
Number
.
isFinite
(
branch
.
sortOrder
)
?
branch
.
sortOrder
:
index
+
1
,
sort_order
:
Number
.
isFinite
(
branch
.
sortOrder
)
?
branch
.
sortOrder
:
index
+
1
,
is_active
:
branch
.
isVisible
,
is_active
:
branch
.
isVisible
,
};
};
}
}
function
mapApiSocialToConfig
(
social
:
SiteInformationSocialLink
):
BaseConfigSocialItem
{
function
mapApiSocialToConfig
(
social
:
SiteInformationSocialLink
,
):
BaseConfigSocialItem
{
return
{
return
{
id
:
social
.
id
,
id
:
social
.
id
,
label
:
social
.
label
,
label
:
social
.
label
,
...
@@ -161,7 +191,9 @@ function mapApiSocialToConfig(social: SiteInformationSocialLink): BaseConfigSoci
...
@@ -161,7 +191,9 @@ function mapApiSocialToConfig(social: SiteInformationSocialLink): BaseConfigSoci
};
};
}
}
function
mapConfigSocialToApi
(
social
:
BaseConfigSocialItem
):
SiteInformationSocialMutate
{
function
mapConfigSocialToApi
(
social
:
BaseConfigSocialItem
,
):
SiteInformationSocialMutate
{
return
{
return
{
url
:
social
.
url
.
trim
()
||
null
,
url
:
social
.
url
.
trim
()
||
null
,
sort_order
:
social
.
sortOrder
,
sort_order
:
social
.
sortOrder
,
...
@@ -190,13 +222,10 @@ function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
...
@@ -190,13 +222,10 @@ function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
};
};
}
}
function
map
SiteLogoToConfig
(
siteInformation
:
SiteInformationData
):
{
function
map
ApiLogoToConfig
(
logo
:
Logo
):
{
logo
:
BaseConfigLogoItem
|
null
;
logo
:
BaseConfigLogoItem
|
null
;
media
:
LogoMediaItem
|
null
;
media
:
LogoMediaItem
|
null
;
}
|
null
{
}
|
null
{
const
logo
=
siteInformation
.
logo
;
if
(
!
logo
)
return
null
;
const
media
:
LogoMediaItem
=
{
const
media
:
LogoMediaItem
=
{
id
:
logo
.
file_id
,
id
:
logo
.
file_id
,
logoId
:
logo
.
id
,
logoId
:
logo
.
id
,
...
@@ -224,8 +253,9 @@ function mapSiteLogoToConfig(siteInformation: SiteInformationData): {
...
@@ -224,8 +253,9 @@ function mapSiteLogoToConfig(siteInformation: SiteInformationData): {
function
applySiteInformationToConfig
(
function
applySiteInformationToConfig
(
baseConfig
:
BaseConfigData
,
baseConfig
:
BaseConfigData
,
siteInformation
:
SiteInformationData
,
siteInformation
:
SiteInformationData
,
logo
?:
Logo
|
null
,
):
BaseConfigData
{
):
BaseConfigData
{
const
logoConfig
=
mapSiteLogoToConfig
(
siteInformation
)
;
const
logoConfig
=
logo
?
mapApiLogoToConfig
(
logo
)
:
null
;
return
{
return
{
...
baseConfig
,
...
baseConfig
,
...
@@ -266,7 +296,12 @@ function ConfigItemPreview({
...
@@ -266,7 +296,12 @@ function ConfigItemPreview({
>
>
<
div
className=
"relative aspect-[16/10] overflow-hidden bg-[#eef4ff]"
>
<
div
className=
"relative aspect-[16/10] overflow-hidden bg-[#eef4ff]"
>
{
media
?
(
{
media
?
(
<
SafeNextImage
src=
{
media
.
url
}
alt=
{
media
.
alt
||
media
.
name
}
fill
className=
"object-cover"
/>
<
SafeNextImage
src=
{
media
.
url
}
alt=
{
media
.
alt
||
media
.
name
}
fill
className=
"object-cover"
/>
)
:
(
)
:
(
<
div
className=
"flex h-full items-center justify-center text-sm text-gray-500"
>
<
div
className=
"flex h-full items-center justify-center text-sm text-gray-500"
>
Chưa chọn hình ảnh
Chưa chọn hình ảnh
...
@@ -274,7 +309,9 @@ function ConfigItemPreview({
...
@@ -274,7 +309,9 @@ function ConfigItemPreview({
)
}
)
}
</
div
>
</
div
>
<
div
className=
"space-y-2 px-4 py-3"
>
<
div
className=
"space-y-2 px-4 py-3"
>
<
div
className=
"line-clamp-1 text-sm font-semibold text-[#163b73]"
>
{
title
}
</
div
>
<
div
className=
"line-clamp-1 text-sm font-semibold text-[#163b73]"
>
{
title
}
</
div
>
<
div
className=
"line-clamp-2 text-sm text-gray-600"
>
{
item
.
name
}
</
div
>
<
div
className=
"line-clamp-2 text-sm text-gray-600"
>
{
item
.
name
}
</
div
>
</
div
>
</
div
>
</
button
>
</
button
>
...
@@ -302,7 +339,10 @@ function ConfigItemDialog({
...
@@ -302,7 +339,10 @@ function ConfigItemDialog({
title
:
string
;
title
:
string
;
description
:
string
;
description
:
string
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onChange
:
<
K
extends
keyof
ConfigItemForm
>
(key: K, value: ConfigItemForm[K]) =
>
void;
onChange
:
<
K
extends
keyof
ConfigItemForm
>
(
key: K,
value: ConfigItemForm[K],
) =
>
void;
onPickImage: () =
>
void;
onPickImage: () =
>
void;
onSubmit: () =
>
void;
onSubmit: () =
>
void;
})
{
})
{
...
@@ -311,8 +351,12 @@ function ConfigItemDialog({
...
@@ -311,8 +351,12 @@ function ConfigItemDialog({
<
DialogContent
className=
"flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0"
>
<
DialogContent
className=
"flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0"
>
<
DialogHeader
>
<
DialogHeader
>
<
div
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
div
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
<
DialogDescription
className=
"mt-2 text-sm text-gray-600"
>
{
description
}
</
DialogDescription
>
{
title
}
</
DialogTitle
>
<
DialogDescription
className=
"mt-2 text-sm text-gray-600"
>
{
description
}
</
DialogDescription
>
</
div
>
</
div
>
</
DialogHeader
>
</
DialogHeader
>
...
@@ -323,21 +367,28 @@ function ConfigItemDialog({
...
@@ -323,21 +367,28 @@ function ConfigItemDialog({
<
Input
<
Input
value=
{
form
.
name
}
value=
{
form
.
name
}
onChange=
{
(
event
)
=>
onChange
(
"name"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
onChange
(
"name"
,
event
.
target
.
value
)
}
placeholder=
{
mode
===
"logo"
?
"Nhập tên logo..."
:
"Nhập tên banner..."
}
placeholder=
{
mode
===
"logo"
?
"Nhập tên logo..."
:
"Nhập tên banner..."
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
{
mode
===
"banner"
?
(
{
mode
===
"banner"
?
(
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Thời gian hiển thị (giây)
</
Label
>
<
Label
className=
"text-gray-700"
>
Thời gian hiển thị (giây)
</
Label
>
<
Input
<
Input
type=
"number"
type=
"number"
min=
{
1
}
min=
{
1
}
max=
{
60
}
max=
{
60
}
value=
{
form
.
displayTimeSeconds
}
value=
{
form
.
displayTimeSeconds
}
onChange=
{
(
event
)
=>
onChange=
{
(
event
)
=>
onChange
(
"displayTimeSeconds"
,
Number
(
event
.
target
.
value
||
1
))
onChange
(
"displayTimeSeconds"
,
Number
(
event
.
target
.
value
||
1
),
)
}
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
...
@@ -351,7 +402,9 @@ function ConfigItemDialog({
...
@@ -351,7 +402,9 @@ function ConfigItemDialog({
type=
"number"
type=
"number"
min=
{
1
}
min=
{
1
}
value=
{
form
.
sortOrder
}
value=
{
form
.
sortOrder
}
onChange=
{
(
event
)
=>
onChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
onChange=
{
(
event
)
=>
onChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -388,13 +441,18 @@ function ConfigItemDialog({
...
@@ -388,13 +441,18 @@ function ConfigItemDialog({
{
mode
===
"banner"
?
(
{
mode
===
"banner"
?
(
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3"
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3"
>
<
div
>
<
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái hiển thị
</
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
<
div
className=
"text-xs text-gray-500"
>
Trạng thái hiển thị
{
form
.
isActive
?
"Đang bật hiển thị"
:
"Đang tắt hiển thị"
}
</
div
>
<
div
className=
"text-xs text-gray-500"
>
{
form
.
isActive
?
"Đang bật hiển thị"
:
"Đang tắt hiển thị"
}
</
div
>
</
div
>
</
div
>
</
div
>
<
Switch
<
Switch
checked=
{
form
.
isActive
}
onCheckedChange=
{
(
value
)
=>
onChange
(
"isActive"
,
value
)
}
/>
checked=
{
form
.
isActive
}
onCheckedChange=
{
(
value
)
=>
onChange
(
"isActive"
,
value
)
}
/>
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
...
@@ -446,14 +504,18 @@ function BranchCard({
...
@@ -446,14 +504,18 @@ function BranchCard({
}`
}
}`
}
>
>
<
button
type=
"button"
onClick=
{
onSelect
}
className=
"w-full text-left"
>
<
button
type=
"button"
onClick=
{
onSelect
}
className=
"w-full text-left"
>
<
div
className=
"text-sm font-semibold text-[#163b73]"
>
{
branch
.
branchName
||
"Chi nhánh mới"
}
</
div
>
<
div
className=
"text-sm font-semibold text-[#163b73]"
>
{
branch
.
branchName
||
"Chi nhánh mới"
}
</
div
>
<
div
className=
"mt-2 line-clamp-2 text-sm leading-6 text-slate-600"
>
<
div
className=
"mt-2 line-clamp-2 text-sm leading-6 text-slate-600"
>
{
branch
.
address
||
"Chưa cập nhật địa chỉ"
}
{
branch
.
address
||
"Chưa cập nhật địa chỉ"
}
</
div
>
</
div
>
</
button
>
</
button
>
<
div
className=
"mt-4 flex items-center justify-between"
>
<
div
className=
"mt-4 flex items-center justify-between"
>
<
div
className=
"text-xs text-slate-500"
>
{
branch
.
hotline
||
"Chưa có hotline"
}
</
div
>
<
div
className=
"text-xs text-slate-500"
>
{
branch
.
hotline
||
"Chưa có hotline"
}
</
div
>
<
Button
<
Button
type=
"button"
type=
"button"
variant=
"ghost"
variant=
"ghost"
...
@@ -473,12 +535,16 @@ export default function AdminBaseConfigPage() {
...
@@ -473,12 +535,16 @@ export default function AdminBaseConfigPage() {
const
[
mediaItems
,
setMediaItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
mediaItems
,
setMediaItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
currentBannerIndex
,
setCurrentBannerIndex
]
=
React
.
useState
(
0
);
const
[
currentBannerIndex
,
setCurrentBannerIndex
]
=
React
.
useState
(
0
);
const
[
currentBranchIndex
,
setCurrentBranchIndex
]
=
React
.
useState
(
0
);
const
[
currentBranchIndex
,
setCurrentBranchIndex
]
=
React
.
useState
(
0
);
const
[
currentBranchId
,
setCurrentBranchId
]
=
React
.
useState
<
string
|
null
>
(
null
);
const
[
currentBranchId
,
setCurrentBranchId
]
=
React
.
useState
<
string
|
null
>
(
null
,
);
const
[
activeTab
,
setActiveTab
]
=
React
.
useState
(
"branding"
);
const
[
activeTab
,
setActiveTab
]
=
React
.
useState
(
"branding"
);
const
[
itemDialogOpen
,
setItemDialogOpen
]
=
React
.
useState
(
false
);
const
[
itemDialogOpen
,
setItemDialogOpen
]
=
React
.
useState
(
false
);
const
[
itemDialogMode
,
setItemDialogMode
]
=
React
.
useState
<
ConfigItemMode
>
(
"logo"
);
const
[
itemDialogMode
,
setItemDialogMode
]
=
React
.
useState
<
ConfigItemMode
>
(
"logo"
);
const
[
editingItemId
,
setEditingItemId
]
=
React
.
useState
<
string
|
null
>
(
null
);
const
[
editingItemId
,
setEditingItemId
]
=
React
.
useState
<
string
|
null
>
(
null
);
const
[
itemForm
,
setItemForm
]
=
React
.
useState
<
ConfigItemForm
>
(
emptyItemForm
());
const
[
itemForm
,
setItemForm
]
=
React
.
useState
<
ConfigItemForm
>
(
emptyItemForm
());
const
[
imagePickerOpen
,
setImagePickerOpen
]
=
React
.
useState
(
false
);
const
[
imagePickerOpen
,
setImagePickerOpen
]
=
React
.
useState
(
false
);
const
[
savingItem
,
setSavingItem
]
=
React
.
useState
(
false
);
const
[
savingItem
,
setSavingItem
]
=
React
.
useState
(
false
);
const
[
savingWebsiteInfo
,
setSavingWebsiteInfo
]
=
React
.
useState
(
false
);
const
[
savingWebsiteInfo
,
setSavingWebsiteInfo
]
=
React
.
useState
(
false
);
...
@@ -497,24 +563,63 @@ export default function AdminBaseConfigPage() {
...
@@ -497,24 +563,63 @@ export default function AdminBaseConfigPage() {
const
loadSiteInformation
=
async
()
=>
{
const
loadSiteInformation
=
async
()
=>
{
try
{
try
{
const
response
=
await
getSiteInformation
();
const
[
siteInformationResponse
,
logoResponse
]
=
await
Promise
.
all
([
const
siteInformation
=
getEnvelopeData
<
SiteInformationData
>
(
response
);
getSiteInformation
(),
getLogo
({
page
:
1
,
pageSize
:
1
,
sortField
:
"updated_at"
,
sortOrder
:
"desc"
,
}),
]);
const
siteInformation
=
getEnvelopeData
<
SiteInformationData
>
(
siteInformationResponse
,
);
const
logoPage
=
getEnvelopeData
<
LogoListResponse
>
(
logoResponse
);
const
currentLogo
=
logoPage
?.
rows
?.[
0
]
??
null
;
if
(
!
mounted
||
!
siteInformation
)
return
;
if
(
!
mounted
)
return
;
const
logoConfig
=
mapSiteLogoToConfig
(
siteInformation
);
if
(
currentLogo
)
{
if
(
logoConfig
?.
media
)
{
const
logoConfig
=
mapApiLogoToConfig
(
currentLogo
);
const
logoMedia
=
logoConfig
.
media
;
if
(
logoConfig
?.
media
)
{
setMediaItems
((
previous
)
=>
{
const
logoMedia
=
logoConfig
.
media
;
const
nextMap
=
new
Map
(
previous
.
map
((
entry
)
=>
[
entry
.
id
,
entry
]));
setMediaItems
((
previous
)
=>
{
nextMap
.
set
(
logoMedia
.
id
,
logoMedia
);
const
nextMap
=
new
Map
(
return
Array
.
from
(
nextMap
.
values
());
previous
.
map
((
entry
)
=>
[
entry
.
id
,
entry
]),
});
);
nextMap
.
set
(
logoMedia
.
id
,
logoMedia
);
return
Array
.
from
(
nextMap
.
values
());
});
}
}
}
setConfig
((
previous
)
=>
if
(
siteInformation
)
{
applySiteInformationToConfig
(
previous
??
baseConfig
,
siteInformation
),
setConfig
((
previous
)
=>
);
applySiteInformationToConfig
(
previous
??
baseConfig
,
siteInformation
,
currentLogo
,
),
);
return
;
}
if
(
currentLogo
)
{
const
logoConfig
=
mapApiLogoToConfig
(
currentLogo
);
setConfig
((
previous
)
=>
previous
?
{
...
previous
,
logo
:
logoConfig
?.
logo
??
previous
.
logo
,
}
:
{
...
baseConfig
,
logo
:
logoConfig
?.
logo
??
baseConfig
.
logo
,
},
);
}
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
error
);
console
.
error
(
error
);
if
(
mounted
)
{
if
(
mounted
)
{
...
@@ -600,10 +705,14 @@ export default function AdminBaseConfigPage() {
...
@@ -600,10 +705,14 @@ export default function AdminBaseConfigPage() {
const
currentLogo
=
config
?.
logo
??
null
;
const
currentLogo
=
config
?.
logo
??
null
;
const
currentBanner
=
sortedBanners
[
currentBannerIndex
]
??
null
;
const
currentBanner
=
sortedBanners
[
currentBannerIndex
]
??
null
;
const
currentBranch
=
const
currentBranch
=
(
currentBranchId
?
sortedBranches
.
find
((
branch
)
=>
branch
.
id
===
currentBranchId
)
:
null
)
??
(
currentBranchId
?
sortedBranches
.
find
((
branch
)
=>
branch
.
id
===
currentBranchId
)
:
null
)
??
sortedBranches
[
currentBranchIndex
]
??
sortedBranches
[
currentBranchIndex
]
??
null
;
null
;
const
currentLogoMedia
=
currentLogo
?
resolveMediaItem
(
mediaMap
,
currentLogo
.
imageId
)
:
null
;
const
currentLogoMedia
=
currentLogo
?
resolveMediaItem
(
mediaMap
,
currentLogo
.
imageId
)
:
null
;
const
currentBannerMedia
=
currentBanner
const
currentBannerMedia
=
currentBanner
?
resolveMediaItem
(
mediaMap
,
currentBanner
.
imageId
)
?
resolveMediaItem
(
mediaMap
,
currentBanner
.
imageId
)
:
null
;
:
null
;
...
@@ -619,19 +728,24 @@ export default function AdminBaseConfigPage() {
...
@@ -619,19 +728,24 @@ export default function AdminBaseConfigPage() {
setEditingItemId
(
null
);
setEditingItemId
(
null
);
setItemForm
({
setItemForm
({
...
emptyItemForm
(),
...
emptyItemForm
(),
sortOrder
:
mode
===
"banner"
?
(
config
?
config
.
banners
.
length
+
1
:
1
)
:
1
,
sortOrder
:
mode
===
"banner"
?
(
config
?
config
.
banners
.
length
+
1
:
1
)
:
1
,
});
});
setItemDialogOpen
(
true
);
setItemDialogOpen
(
true
);
};
};
const
openEditDialog
=
(
mode
:
ConfigItemMode
,
item
:
BaseConfigLogoItem
|
BaseConfigBannerItem
)
=>
{
const
openEditDialog
=
(
mode
:
ConfigItemMode
,
item
:
BaseConfigLogoItem
|
BaseConfigBannerItem
,
)
=>
{
setItemDialogMode
(
mode
);
setItemDialogMode
(
mode
);
setEditingItemId
(
item
.
id
);
setEditingItemId
(
item
.
id
);
setItemForm
({
setItemForm
({
name
:
item
.
name
,
name
:
item
.
name
,
imageId
:
item
.
imageId
,
imageId
:
item
.
imageId
,
isActive
:
item
.
isActive
,
isActive
:
item
.
isActive
,
displayTimeSeconds
:
"displayTimeSeconds"
in
item
?
item
.
displayTimeSeconds
:
5
,
displayTimeSeconds
:
"displayTimeSeconds"
in
item
?
item
.
displayTimeSeconds
:
5
,
sortOrder
:
"sortOrder"
in
item
?
item
.
sortOrder
:
1
,
sortOrder
:
"sortOrder"
in
item
?
item
.
sortOrder
:
1
,
});
});
setItemDialogOpen
(
true
);
setItemDialogOpen
(
true
);
...
@@ -669,7 +783,8 @@ export default function AdminBaseConfigPage() {
...
@@ -669,7 +783,8 @@ export default function AdminBaseConfigPage() {
logo_url
:
selectedMedia
?.
url
??
null
,
logo_url
:
selectedMedia
?.
url
??
null
,
file_id
:
itemForm
.
imageId
,
file_id
:
itemForm
.
imageId
,
});
});
const
savedLogo
=
getEnvelopeData
<
NonNullable
<
SiteInformationData
[
"logo"
]
>>
(
response
);
const
savedLogo
=
getEnvelopeData
<
NonNullable
<
SiteInformationData
[
"logo"
]
>>
(
response
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
logo
=
{
nextConfig
.
logo
=
{
...
@@ -705,7 +820,9 @@ export default function AdminBaseConfigPage() {
...
@@ -705,7 +820,9 @@ export default function AdminBaseConfigPage() {
?
await
putBannerId
(
editingItemId
,
mapConfigBannerToApi
(
bannerDraft
))
?
await
putBannerId
(
editingItemId
,
mapConfigBannerToApi
(
bannerDraft
))
:
await
postBanner
(
mapConfigBannerToApi
(
bannerDraft
));
:
await
postBanner
(
mapConfigBannerToApi
(
bannerDraft
));
const
savedBanner
=
getEnvelopeData
<
Banner
>
(
response
);
const
savedBanner
=
getEnvelopeData
<
Banner
>
(
response
);
const
nextBanner
=
savedBanner
?
mapApiBannerToConfig
(
savedBanner
)
:
bannerDraft
;
const
nextBanner
=
savedBanner
?
mapApiBannerToConfig
(
savedBanner
)
:
bannerDraft
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
editingItemId
)
{
if
(
editingItemId
)
{
...
@@ -731,38 +848,34 @@ export default function AdminBaseConfigPage() {
...
@@ -731,38 +848,34 @@ export default function AdminBaseConfigPage() {
const
nextConfig
=
cloneBaseConfigData
(
config
!
);
const
nextConfig
=
cloneBaseConfigData
(
config
!
);
if
(
editingItemId
)
{
if
(
editingItemId
)
{
nextConfig
.
banners
=
nextConfig
.
banners
.
map
((
item
)
=>
nextConfig
.
banners
=
nextConfig
.
banners
.
map
((
item
)
=>
item
.
id
===
editingItemId
item
.
id
===
editingItemId
?
{
?
{
...
item
,
...
item
,
name
:
trimmedName
,
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
sortOrder
:
itemForm
.
sortOrder
,
}
}
:
item
,
:
item
,
);
);
}
else
{
}
else
{
nextConfig
.
banners
.
push
({
nextConfig
.
banners
.
push
({
id
:
createBaseConfigItemId
(
"banner"
),
id
:
createBaseConfigItemId
(
"banner"
),
name
:
trimmedName
,
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
sortOrder
:
itemForm
.
sortOrder
,
});
});
setCurrentBannerIndex
(
Math
.
max
(
nextConfig
.
banners
.
length
-
1
,
0
));
setCurrentBannerIndex
(
Math
.
max
(
nextConfig
.
banners
.
length
-
1
,
0
));
}
}
saveConfig
(
nextConfig
);
saveConfig
(
nextConfig
);
setSavingItem
(
false
);
setSavingItem
(
false
);
setItemDialogOpen
(
false
);
setItemDialogOpen
(
false
);
toast
.
success
(
toast
.
success
(
false
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
);
false
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
,
);
};
};
const
handleDeleteItem
=
async
()
=>
{
const
handleDeleteItem
=
async
()
=>
{
...
@@ -771,28 +884,32 @@ export default function AdminBaseConfigPage() {
...
@@ -771,28 +884,32 @@ export default function AdminBaseConfigPage() {
try
{
try
{
const
nextConfig
=
cloneBaseConfigData
(
config
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
deleteTarget
.
mode
===
"logo"
)
{
if
(
deleteTarget
.
mode
===
"logo"
)
{
try
{
try
{
await
deleteLogoId
(
deleteTarget
.
id
);
await
deleteLogoId
(
deleteTarget
.
id
);
nextConfig
.
logo
=
null
;
nextConfig
.
logo
=
null
;
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
error
);
console
.
error
(
error
);
toast
.
error
(
"Không thể xóa cấu hình logo"
);
toast
.
error
(
"Không thể xóa cấu hình logo"
);
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
return
;
return
;
}
}
else
{
await
deleteBannerId
(
deleteTarget
.
id
);
nextConfig
.
banners
=
nextConfig
.
banners
.
filter
(
(
item
)
=>
item
.
id
!==
deleteTarget
.
id
,
);
setCurrentBannerIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
nextConfig
.
banners
.
length
-
1
)),
);
}
}
}
else
{
await
deleteBannerId
(
deleteTarget
.
id
);
nextConfig
.
banners
=
nextConfig
.
banners
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setCurrentBannerIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
nextConfig
.
banners
.
length
-
1
)),
);
}
saveConfig
(
nextConfig
);
saveConfig
(
nextConfig
);
toast
.
success
(
"Đã xóa cấu hình"
);
toast
.
success
(
"Đã xóa cấu hình"
);
}
catch
(
err
)
{
}
catch
(
err
)
{
toast
.
error
(
err
instanceof
Error
?
err
.
message
:
"Không thể xóa cấu hình"
);
toast
.
error
(
err
instanceof
Error
?
err
.
message
:
"Không thể xóa cấu hình"
,
);
}
finally
{
}
finally
{
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
}
}
...
@@ -809,7 +926,9 @@ export default function AdminBaseConfigPage() {
...
@@ -809,7 +926,9 @@ export default function AdminBaseConfigPage() {
?
{
?
{
...
previous
,
...
previous
,
branches
:
previous
.
branches
.
map
((
branch
)
=>
branches
:
previous
.
branches
.
map
((
branch
)
=>
branch
.
id
===
currentBranch
.
id
?
{
...
branch
,
[
key
]:
value
}
:
branch
,
branch
.
id
===
currentBranch
.
id
?
{
...
branch
,
[
key
]:
value
}
:
branch
,
),
),
}
}
:
previous
,
:
previous
,
...
@@ -836,10 +955,15 @@ export default function AdminBaseConfigPage() {
...
@@ -836,10 +955,15 @@ export default function AdminBaseConfigPage() {
if
(
!
config
)
return
;
if
(
!
config
)
return
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
((
branch
)
=>
branch
.
id
!==
branchId
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
(
(
branch
)
=>
branch
.
id
!==
branchId
,
);
saveConfig
(
nextConfig
);
saveConfig
(
nextConfig
);
setCurrentBranchIndex
((
previous
)
=>
setCurrentBranchIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
Math
.
max
(
nextConfig
.
branches
.
length
-
1
,
0
))),
Math
.
max
(
0
,
Math
.
min
(
previous
,
Math
.
max
(
nextConfig
.
branches
.
length
-
1
,
0
)),
),
);
);
toast
.
success
(
"Đã xóa chi nhánh"
);
toast
.
success
(
"Đã xóa chi nhánh"
);
}
;
}
;
...
@@ -901,10 +1025,15 @@ export default function AdminBaseConfigPage() {
...
@@ -901,10 +1025,15 @@ export default function AdminBaseConfigPage() {
await
deleteSiteInformationBranchesId
(
branchId
);
await
deleteSiteInformationBranchesId
(
branchId
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
((
branch
)
=>
branch
.
id
!==
branchId
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
(
(
branch
)
=>
branch
.
id
!==
branchId
,
);
setConfig
(
nextConfig
);
setConfig
(
nextConfig
);
setCurrentBranchIndex
((
previous
)
=>
setCurrentBranchIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
Math
.
max
(
nextConfig
.
branches
.
length
-
1
,
0
))),
Math
.
max
(
0
,
Math
.
min
(
previous
,
Math
.
max
(
nextConfig
.
branches
.
length
-
1
,
0
)),
),
);
);
setCurrentBranchId
(
null
);
setCurrentBranchId
(
null
);
toast
.
success
(
"Đã xóa chi nhánh"
);
toast
.
success
(
"Đã xóa chi nhánh"
);
...
@@ -923,7 +1052,10 @@ export default function AdminBaseConfigPage() {
...
@@ -923,7 +1052,10 @@ export default function AdminBaseConfigPage() {
try
{
try
{
await
Promise
.
all
(
await
Promise
.
all
(
sortBaseConfigBranches
(
config
.
branches
).
map
((
branch
,
index
)
=>
sortBaseConfigBranches
(
config
.
branches
).
map
((
branch
,
index
)
=>
patchSiteInformationBranchesId
(
branch
.
id
,
mapConfigBranchToApi
(
branch
,
index
)),
patchSiteInformationBranchesId
(
branch
.
id
,
mapConfigBranchToApi
(
branch
,
index
),
),
),
),
);
);
setConfig
(
config
);
setConfig
(
config
);
...
@@ -936,8 +1068,13 @@ export default function AdminBaseConfigPage() {
...
@@ -936,8 +1068,13 @@ export default function AdminBaseConfigPage() {
}
}
}
;
}
;
const handleWebsiteInfoChange = (key: "websiteName" | "websiteLink", value: string) =
>
{
const handleWebsiteInfoChange = (
setConfig
((
previous
)
=>
(
previous
?
{
...
previous
,
[
key
]:
value
}
:
previous
));
key: "websiteName" | "websiteLink",
value: string,
) =
>
{
setConfig
((
previous
)
=>
previous
?
{
...
previous
,
[
key
]:
value
}
:
previous
,
);
}
;
}
;
const handleSaveWebsiteInfo = async () =
>
{
const handleSaveWebsiteInfo = async () =
>
{
...
@@ -998,7 +1135,10 @@ export default function AdminBaseConfigPage() {
...
@@ -998,7 +1135,10 @@ export default function AdminBaseConfigPage() {
try
{
try
{
await
Promise
.
all
(
await
Promise
.
all
(
config
.
socials
.
map
((
social
)
=>
config
.
socials
.
map
((
social
)
=>
patchSiteInformationSocialsId
(
social
.
id
,
mapConfigSocialToApi
(
social
)),
patchSiteInformationSocialsId
(
social
.
id
,
mapConfigSocialToApi
(
social
),
),
),
),
);
);
setConfig
(
config
);
setConfig
(
config
);
...
@@ -1021,33 +1161,37 @@ export default function AdminBaseConfigPage() {
...
@@ -1021,33 +1161,37 @@ export default function AdminBaseConfigPage() {
return (
return (
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
Tabs
value=
{
activeTab
}
onValueChange=
{
setActiveTab
}
className=
"space-y-5"
>
<
Tabs
value=
{
activeTab
}
onValueChange=
{
setActiveTab
}
className=
"space-y-5"
>
<
div
className=
"overflow-x-auto pb-1"
>
<
div
className=
"overflow-x-auto pb-1"
>
<
TabsList
className=
"h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5"
>
<
TabsList
className=
"h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5"
>
<
TabsTrigger
<
TabsTrigger
value=
"branding"
value=
"branding"
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]"
>
>
Nhận diện thương hiệu
Nhận diện thương hiệu
</
TabsTrigger
>
</
TabsTrigger
>
<
TabsTrigger
<
TabsTrigger
value=
"banner"
value=
"banner"
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]"
>
>
Banner trang chủ
Banner trang chủ
</
TabsTrigger
>
</
TabsTrigger
>
<
TabsTrigger
<
TabsTrigger
value=
"contact"
value=
"contact"
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]"
>
>
Thông tin liên hệ
Thông tin liên hệ
</
TabsTrigger
>
</
TabsTrigger
>
<
TabsTrigger
<
TabsTrigger
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
>
</
div
>
</
div
>
...
@@ -1056,7 +1200,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1056,7 +1200,9 @@ export default function AdminBaseConfigPage() {
<
CardHeader
className=
"pb-5"
>
<
CardHeader
className=
"pb-5"
>
<
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]"
>
Nhận diện thương hiệu
</
CardTitle
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Nhận diện thương hiệu
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý logo hiển thị trên website.
Quản lý logo hiển thị trên website.
</
CardDescription
>
</
CardDescription
>
...
@@ -1133,13 +1279,20 @@ export default function AdminBaseConfigPage() {
...
@@ -1133,13 +1279,20 @@ export default function AdminBaseConfigPage() {
<
div
className=
"text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]"
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]"
>
Logo website
Logo website
</
div
>
</
div
>
<
div
className=
"mt-3 font-semibold text-[#163b73]"
>
{
currentLogo
.
name
}
</
div
>
<
div
className=
"mt-3 font-semibold text-[#163b73]"
>
{
currentLogo
.
name
}
</
div
>
</
div
>
</
div
>
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Tên website
</
Label
>
<
Label
className=
"text-gray-700"
>
Tên website
</
Label
>
<
Input
<
Input
value=
{
config
.
websiteName
}
value=
{
config
.
websiteName
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteName"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteName"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1147,7 +1300,12 @@ export default function AdminBaseConfigPage() {
...
@@ -1147,7 +1300,12 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Link website
</
Label
>
<
Label
className=
"text-gray-700"
>
Link website
</
Label
>
<
Input
<
Input
value=
{
config
.
websiteLink
}
value=
{
config
.
websiteLink
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteLink"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteLink"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1156,7 +1314,10 @@ export default function AdminBaseConfigPage() {
...
@@ -1156,7 +1314,10 @@ export default function AdminBaseConfigPage() {
Trạng thái
Trạng thái
</
div
>
</
div
>
<
div
className=
"mt-2 flex items-center gap-2"
>
<
div
className=
"mt-2 flex items-center gap-2"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
{
currentLogo
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
{
currentLogo
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
Badge
>
</
Badge
>
</
div
>
</
div
>
...
@@ -1168,7 +1329,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1168,7 +1329,9 @@ export default function AdminBaseConfigPage() {
className=
"w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
className=
"w-full 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"
/>
{
savingWebsiteInfo
?
"Đang lưu..."
:
"Lưu thông tin website"
}
{
savingWebsiteInfo
?
"Đang lưu..."
:
"Lưu thông tin website"
}
</
Button
>
</
Button
>
</
div
>
</
div
>
)
:
(
)
:
(
...
@@ -1187,9 +1350,12 @@ export default function AdminBaseConfigPage() {
...
@@ -1187,9 +1350,12 @@ export default function AdminBaseConfigPage() {
<
CardHeader
className=
"pb-5"
>
<
CardHeader
className=
"pb-5"
>
<
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]"
>
Banner trang chủ
</
CardTitle
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Banner trang chủ
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang chủ của website.
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang
chủ của website.
</
CardDescription
>
</
CardDescription
>
</
div
>
</
div
>
...
@@ -1264,7 +1430,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1264,7 +1430,9 @@ export default function AdminBaseConfigPage() {
className=
"rounded-xl border-[#063e8e]/15"
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
onClick=
{
()
=>
setCurrentBannerIndex
((
previous
)
=>
setCurrentBannerIndex
((
previous
)
=>
previous
<=
0
?
Math
.
max
(
sortedBanners
.
length
-
1
,
0
)
:
previous
-
1
,
previous
<=
0
?
Math
.
max
(
sortedBanners
.
length
-
1
,
0
)
:
previous
-
1
,
)
)
}
}
disabled=
{
sortedBanners
.
length
<=
1
}
disabled=
{
sortedBanners
.
length
<=
1
}
...
@@ -1278,7 +1446,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1278,7 +1446,9 @@ export default function AdminBaseConfigPage() {
className=
"rounded-xl border-[#063e8e]/15"
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
onClick=
{
()
=>
setCurrentBannerIndex
((
previous
)
=>
setCurrentBannerIndex
((
previous
)
=>
sortedBanners
.
length
===
0
?
0
:
(
previous
+
1
)
%
sortedBanners
.
length
,
sortedBanners
.
length
===
0
?
0
:
(
previous
+
1
)
%
sortedBanners
.
length
,
)
)
}
}
disabled=
{
sortedBanners
.
length
<=
1
}
disabled=
{
sortedBanners
.
length
<=
1
}
...
@@ -1304,14 +1474,20 @@ export default function AdminBaseConfigPage() {
...
@@ -1304,14 +1474,20 @@ export default function AdminBaseConfigPage() {
{
currentBanner
?
(
{
currentBanner
?
(
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Tên banner
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
name
}
</
div
>
Tên banner
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
name
}
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Thứ tự hiển thị
Thứ tự hiển thị
</
div
>
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
sortOrder
}
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
sortOrder
}
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
...
@@ -1322,9 +1498,14 @@ export default function AdminBaseConfigPage() {
...
@@ -1322,9 +1498,14 @@ export default function AdminBaseConfigPage() {
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Trạng thái
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Trạng thái
</
div
>
<
div
className=
"mt-2"
>
<
div
className=
"mt-2"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
{
currentBanner
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
{
currentBanner
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
Badge
>
</
Badge
>
</
div
>
</
div
>
...
@@ -1340,7 +1521,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1340,7 +1521,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]"
>
Thông tin liên hệ website
</
CardTitle
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Thông tin liên hệ website
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</
CardDescription
>
</
CardDescription
>
...
@@ -1396,7 +1579,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1396,7 +1579,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Tên chi nhánh
</
Label
>
<
Label
className=
"text-gray-700"
>
Tên chi nhánh
</
Label
>
<
Input
<
Input
value=
{
currentBranch
.
branchName
}
value=
{
currentBranch
.
branchName
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"branchName"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"branchName"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1405,7 +1590,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1405,7 +1590,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Địa chỉ
</
Label
>
<
Label
className=
"text-gray-700"
>
Địa chỉ
</
Label
>
<
Textarea
<
Textarea
value=
{
currentBranch
.
address
}
value=
{
currentBranch
.
address
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"address"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"address"
,
event
.
target
.
value
)
}
className=
{
`${fieldClassName} min-h-[110px]`
}
className=
{
`${fieldClassName} min-h-[110px]`
}
/>
/>
</
div
>
</
div
>
...
@@ -1415,7 +1602,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1415,7 +1602,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Hotline
</
Label
>
<
Label
className=
"text-gray-700"
>
Hotline
</
Label
>
<
Input
<
Input
value=
{
currentBranch
.
hotline
}
value=
{
currentBranch
.
hotline
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"hotline"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"hotline"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1423,7 +1612,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1423,7 +1612,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Email
</
Label
>
<
Label
className=
"text-gray-700"
>
Email
</
Label
>
<
Input
<
Input
value=
{
currentBranch
.
email
}
value=
{
currentBranch
.
email
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"email"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"email"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1434,7 +1625,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1434,7 +1625,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Fax
</
Label
>
<
Label
className=
"text-gray-700"
>
Fax
</
Label
>
<
Input
<
Input
value=
{
currentBranch
.
fax
}
value=
{
currentBranch
.
fax
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"fax"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"fax"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1442,7 +1635,12 @@ export default function AdminBaseConfigPage() {
...
@@ -1442,7 +1635,12 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Google Maps
</
Label
>
<
Label
className=
"text-gray-700"
>
Google Maps
</
Label
>
<
Input
<
Input
value=
{
currentBranch
.
mapsEmbedUrl
}
value=
{
currentBranch
.
mapsEmbedUrl
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"mapsEmbedUrl"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"mapsEmbedUrl"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
...
@@ -1456,28 +1654,38 @@ export default function AdminBaseConfigPage() {
...
@@ -1456,28 +1654,38 @@ export default function AdminBaseConfigPage() {
min=
{
1
}
min=
{
1
}
value=
{
currentBranch
.
sortOrder
}
value=
{
currentBranch
.
sortOrder
}
onChange=
{
(
event
)
=>
onChange=
{
(
event
)
=>
handleBranchChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
handleBranchChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
),
)
}
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
</
div
>
</
div
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<
div
>
<
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái hiển thị
</
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái hiển thị
</
div
>
<
div
className=
"text-xs text-gray-500"
>
<
div
className=
"text-xs text-gray-500"
>
{
currentBranch
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
{
currentBranch
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
div
>
</
div
>
</
div
>
</
div
>
<
Switch
<
Switch
checked=
{
currentBranch
.
isVisible
}
checked=
{
currentBranch
.
isVisible
}
onCheckedChange=
{
(
value
)
=>
handleBranchChange
(
"isVisible"
,
value
)
}
onCheckedChange=
{
(
value
)
=>
handleBranchChange
(
"isVisible"
,
value
)
}
/>
/>
</
div
>
</
div
>
</
div
>
</
div
>
</>
</>
)
:
(
)
:
(
<
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ưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu
hình
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
...
@@ -1490,7 +1698,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1490,7 +1698,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
>
...
@@ -1519,11 +1729,17 @@ export default function AdminBaseConfigPage() {
...
@@ -1519,11 +1729,17 @@ export default function AdminBaseConfigPage() {
<
Checkbox
<
Checkbox
checked=
{
item
.
isVisible
}
checked=
{
item
.
isVisible
}
onCheckedChange=
{
(
checked
)
=>
onCheckedChange=
{
(
checked
)
=>
handleSocialChange
(
item
.
id
,
"isVisible"
,
checked
===
true
)
handleSocialChange
(
item
.
id
,
"isVisible"
,
checked
===
true
,
)
}
}
/>
/>
<
div
>
<
div
>
<
div
className=
"font-semibold text-[#163b73]"
>
{
item
.
label
}
</
div
>
<
div
className=
"font-semibold text-[#163b73]"
>
{
item
.
label
}
</
div
>
<
div
className=
"text-sm text-slate-500"
>
<
div
className=
"text-sm text-slate-500"
>
{
item
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
{
item
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
div
>
</
div
>
...
@@ -1534,7 +1750,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1534,7 +1750,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Link URL
</
Label
>
<
Label
className=
"text-gray-700"
>
Link URL
</
Label
>
<
Input
<
Input
value=
{
item
.
url
}
value=
{
item
.
url
}
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"url"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"url"
,
event
.
target
.
value
)
}
placeholder=
{
`Nhập link ${item.label}
...
`
}
placeholder=
{
`Nhập link ${item.label}
...
`
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
...
@@ -1547,7 +1765,11 @@ export default function AdminBaseConfigPage() {
...
@@ -1547,7 +1765,11 @@ export default function AdminBaseConfigPage() {
min=
{
1
}
min=
{
1
}
value=
{
item
.
sortOrder
}
value=
{
item
.
sortOrder
}
onChange=
{
(
event
)
=>
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
handleSocialChange
(
item
.
id
,
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
),
)
}
}
className=
{
fieldClassName
}
className=
{
fieldClassName
}
/>
/>
...
@@ -1581,7 +1803,9 @@ export default function AdminBaseConfigPage() {
...
@@ -1581,7 +1803,9 @@ export default function AdminBaseConfigPage() {
:
"Thiết lập banner hiển thị cho trang chủ."
:
"Thiết lập banner hiển thị cho trang chủ."
}
}
onOpenChange=
{
setItemDialogOpen
}
onOpenChange=
{
setItemDialogOpen
}
onChange=
{
(
key
,
value
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}))
}
onChange=
{
(
key
,
value
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}))
}
onPickImage=
{
()
=>
setImagePickerOpen
(
true
)
}
onPickImage=
{
()
=>
setImagePickerOpen
(
true
)
}
onSubmit=
{
handleSubmitItem
}
onSubmit=
{
handleSubmitItem
}
/>
/>
...
@@ -1605,7 +1829,8 @@ export default function AdminBaseConfigPage() {
...
@@ -1605,7 +1829,8 @@ export default function AdminBaseConfigPage() {
title=
"Xóa cấu hình"
title=
"Xóa cấu hình"
description=
{
description=
{
<>
<>
Bạn có chắc muốn xóa
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành
Bạn có chắc muốn xóa
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành
động này không thể hoàn tác.
động này không thể hoàn tác.
</>
</>
}
}
...
...
src/app/admin/login/page.tsx
View file @
91a2bc40
...
@@ -14,6 +14,9 @@ import {
...
@@ -14,6 +14,9 @@ import {
ShieldCheck
,
ShieldCheck
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
getLogo
}
from
"@/api/endpoints/logo"
;
import
type
{
Logo
}
from
"@/api/models/logo"
;
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"
;
...
@@ -35,6 +38,14 @@ type ApiEnvelope<T = unknown> = {
...
@@ -35,6 +38,14 @@ type ApiEnvelope<T = unknown> = {
message_en
?:
string
|
null
;
message_en
?:
string
|
null
;
};
};
type
LogoListEnvelope
=
{
data
?:
{
responseData
?:
{
rows
?:
Logo
[];
};
};
};
type
VerifyOtpPayload
=
{
type
VerifyOtpPayload
=
{
reset_token
?:
string
;
reset_token
?:
string
;
expires_in
?:
number
;
expires_in
?:
number
;
...
@@ -58,7 +69,11 @@ const authButtonClassName =
...
@@ -58,7 +69,11 @@ const authButtonClassName =
"h-11 rounded-xl bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.16)] hover:bg-[#052f6c]"
;
"h-11 rounded-xl bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.16)] hover:bg-[#052f6c]"
;
function
normalizeRedirectPath
(
redirect
:
string
|
null
)
{
function
normalizeRedirectPath
(
redirect
:
string
|
null
)
{
if
(
!
redirect
||
!
redirect
.
startsWith
(
"/admin"
)
||
redirect
===
"/admin/login"
)
{
if
(
!
redirect
||
!
redirect
.
startsWith
(
"/admin"
)
||
redirect
===
"/admin/login"
)
{
return
DEFAULT_REDIRECT
;
return
DEFAULT_REDIRECT
;
}
}
...
@@ -95,7 +110,8 @@ async function postAuthJson<TResponse, TBody>(path: string, body: TBody) {
...
@@ -95,7 +110,8 @@ async function postAuthJson<TResponse, TBody>(path: string, body: TBody) {
body
:
JSON
.
stringify
(
body
),
body
:
JSON
.
stringify
(
body
),
});
});
const
data
=
(
await
response
.
json
().
catch
(()
=>
({})))
as
TResponse
&
ErrorResponse
;
const
data
=
(
await
response
.
json
().
catch
(()
=>
({})))
as
TResponse
&
ErrorResponse
;
if
(
!
response
.
ok
)
{
if
(
!
response
.
ok
)
{
throw
{
throw
{
...
@@ -115,6 +131,13 @@ function AuthShell({
...
@@ -115,6 +131,13 @@ function AuthShell({
mode
:
AuthMode
;
mode
:
AuthMode
;
children
:
React
.
ReactNode
;
children
:
React
.
ReactNode
;
})
{
})
{
const
{
data
:
logoData
}
=
useQuery
({
queryKey
:
[
"logo"
,
{
page
:
1
,
pageSize
:
1
,
sortOrder
:
"desc"
}],
queryFn
:
()
=>
getLogo
({
page
:
1
,
pageSize
:
1
,
sortOrder
:
"desc"
}),
select
:
(
response
)
=>
(
response
as
LogoListEnvelope
)?.
data
?.
responseData
?.
rows
?.[
0
],
});
const
title
=
const
title
=
mode
===
"login"
mode
===
"login"
?
"Đăng nhập quản trị"
?
"Đăng nhập quản trị"
...
@@ -139,13 +162,22 @@ function AuthShell({
...
@@ -139,13 +162,22 @@ function AuthShell({
<
div
>
<
div
>
<
div
className=
"flex items-center gap-4"
>
<
div
className=
"flex items-center gap-4"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-white shadow-sm"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-white shadow-sm"
>
<
Image
src=
{
logo
}
alt=
"VCCI HCM"
className=
"h-12 w-12 object-contain"
priority
/>
<
Image
src=
{
logoData
?.
logo_url
||
logo
}
alt=
{
logoData
?.
logo_name
||
"VCCI HCM"
}
width=
{
48
}
height=
{
48
}
className=
"h-12 w-12 object-contain"
priority
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
div
className=
"text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]"
>
<
div
className=
"text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]"
>
VCCI News
{
logoData
?.
logo_name
||
"VCCI News"
}
</
div
>
<
div
className=
"mt-1 text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
<
div
className=
"mt-1 text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -158,16 +190,21 @@ function AuthShell({
...
@@ -158,16 +190,21 @@ function AuthShell({
Quản lý nội dung với giao diện riêng cho admin.
Quản lý nội dung với giao diện riêng cho admin.
</
h1
>
</
h1
>
<
p
className=
"mt-5 text-base leading-7 text-gray-700"
>
<
p
className=
"mt-5 text-base leading-7 text-gray-700"
>
Hệ thống sử dụng tài khoản quản trị để bảo vệ cấu hình
website, bài viết,
Hệ thống sử dụng tài khoản quản trị để bảo vệ cấu hình
media và các dữ liệu vận hành.
website, bài viết,
media và các dữ liệu vận hành.
</
p
>
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"grid grid-cols-3 gap-3"
>
<
div
className=
"grid grid-cols-3 gap-3"
>
{
[
"Cấu hình"
,
"Bài viết"
,
"Liên hệ"
].
map
((
item
)
=>
(
{
[
"Cấu hình"
,
"Bài viết"
,
"Liên hệ"
].
map
((
item
)
=>
(
<
div
key=
{
item
}
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<
div
<
div
className=
"text-sm font-semibold text-[#063e8e]"
>
{
item
}
</
div
>
key=
{
item
}
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<
div
className=
"text-sm font-semibold text-[#063e8e]"
>
{
item
}
</
div
>
<
div
className=
"mt-1 h-1.5 rounded-full bg-[#dbe8ff]"
/>
<
div
className=
"mt-1 h-1.5 rounded-full bg-[#dbe8ff]"
/>
</
div
>
</
div
>
))
}
))
}
...
@@ -179,13 +216,22 @@ function AuthShell({
...
@@ -179,13 +216,22 @@ function AuthShell({
<
div
className=
"mx-auto w-full max-w-md"
>
<
div
className=
"mx-auto w-full max-w-md"
>
<
div
className=
"mb-8 flex items-center gap-3 lg:hidden"
>
<
div
className=
"mb-8 flex items-center gap-3 lg:hidden"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff]"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff]"
>
<
Image
src=
{
logo
}
alt=
"VCCI HCM"
className=
"h-9 w-9 object-contain"
priority
/>
<
Image
src=
{
logoData
?.
logo_url
||
logo
}
alt=
{
logoData
?.
logo_name
||
"VCCI HCM"
}
width=
{
36
}
height=
{
36
}
className=
"h-9 w-9 object-contain"
priority
/>
</
div
>
</
div
>
<
div
>
<
div
>
<
div
className=
"text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]"
>
<
div
className=
"text-sm font-bold uppercase tracking-[0.2em] text-[#063e8e]"
>
VCCI News
{
logoData
?.
logo_name
||
"VCCI News"
}
</
div
>
<
div
className=
"text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
<
div
className=
"text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -193,8 +239,12 @@ function AuthShell({
...
@@ -193,8 +239,12 @@ function AuthShell({
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"
>
<
LockKeyhole
className=
"h-6 w-6"
/>
<
LockKeyhole
className=
"h-6 w-6"
/>
</
div
>
</
div
>
<
h2
className=
"mt-5 text-2xl font-bold text-gray-900"
>
{
title
}
</
h2
>
<
h2
className=
"mt-5 text-2xl font-bold text-gray-900"
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
{
description
}
</
p
>
{
title
}
</
h2
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
{
description
}
</
p
>
</
div
>
</
div
>
{
children
}
{
children
}
...
@@ -263,7 +313,9 @@ function InlineMessage({
...
@@ -263,7 +313,9 @@ function InlineMessage({
}
}
>
>
<
div
className=
"flex items-start gap-2"
>
<
div
className=
"flex items-start gap-2"
>
{
type
===
"success"
?
<
CheckCircle2
className=
"mt-0.5 h-4 w-4 shrink-0"
/>
:
null
}
{
type
===
"success"
?
(
<
CheckCircle2
className=
"mt-0.5 h-4 w-4 shrink-0"
/>
)
:
null
}
<
span
>
{
message
}
</
span
>
<
span
>
{
message
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -314,12 +366,18 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -314,12 +366,18 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
try
{
try
{
await
loginAdmin
(
email
.
trim
(),
password
,
{
persistSession
:
remember
});
await
loginAdmin
(
email
.
trim
(),
password
,
{
persistSession
:
remember
});
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"
);
router
.
replace
(
redirect
);
router
.
replace
(
redirect
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setLoginError
(
getAuthErrorMessage
(
error
,
"Đăng nhập thất bại. Vui lòng thử lại."
));
setLoginError
(
getAuthErrorMessage
(
error
,
"Đăng nhập thất bại. Vui lòng thử lại."
),
);
}
finally
{
}
finally
{
setLoginLoading
(
false
);
setLoginLoading
(
false
);
}
}
...
@@ -332,15 +390,20 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -332,15 +390,20 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
setResetLoading
(
true
);
try
{
try
{
await
postAuthJson
<
ApiEnvelope
,
{
email
:
string
}
>
(
"/auth/forgot-password/send-otp"
,
{
await
postAuthJson
<
ApiEnvelope
,
{
email
:
string
}
>
(
email
:
email
.
trim
(),
"/auth/forgot-password/send-otp"
,
});
{
email
:
email
.
trim
(),
},
);
setResetStep
(
"verify"
);
setResetStep
(
"verify"
);
setMode
(
"reset"
);
setMode
(
"reset"
);
setResetMessage
(
"M? OTP d? du?c g?i d?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
);
}
}
...
@@ -353,13 +416,13 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -353,13 +416,13 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
setResetLoading
(
true
);
try
{
try
{
const
response
=
await
postAuthJson
<
ApiEnvelope
<
VerifyOtpPayload
>
,
{
email
:
string
;
otp
:
string
}
>
(
const
response
=
await
postAuthJson
<
"/auth/forgot-password/verify-otp"
,
ApiEnvelope
<
VerifyOtpPayload
>
,
{
{
email
:
string
;
otp
:
string
}
email
:
email
.
trim
(),
>
(
"/auth/forgot-password/verify-otp"
,
{
otp
:
otp
.
trim
(),
email
:
email
.
trim
(),
}
,
otp
:
otp
.
trim
()
,
);
}
);
const
payload
=
getResponseData
<
VerifyOtpPayload
>
(
response
);
const
payload
=
getResponseData
<
VerifyOtpPayload
>
(
response
);
if
(
!
payload
?.
reset_token
)
{
if
(
!
payload
?.
reset_token
)
{
...
@@ -370,7 +433,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -370,7 +433,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
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 d? 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
);
}
}
...
@@ -394,22 +459,29 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -394,22 +459,29 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
setResetLoading
(
true
);
try
{
try
{
await
postAuthJson
<
ApiEnvelope
,
{
reset_token
:
string
;
new_password
:
string
}
>
(
await
postAuthJson
<
"/auth/forgot-password/reset"
,
ApiEnvelope
,
{
{
reset_token
:
string
;
new_password
:
string
}
reset_token
:
resetToken
,
>
(
"/auth/forgot-password/reset"
,
{
new_password
:
newPassword
,
reset_token
:
resetToken
,
}
,
new_password
:
newPassword
,
);
}
);
setResetStep
(
"done"
);
setResetStep
(
"done"
);
setResetMessage
(
"Đặt lại mật khẩu thành công. Bạn có thể đăng nhập bằng mật khẩu mới."
);
setResetMessage
(
"Đặt lại mật khẩu thành công. Bạn có thể đăng nhập bằng mật khẩu mới."
,
);
setPassword
(
""
);
setPassword
(
""
);
setNewPassword
(
""
);
setNewPassword
(
""
);
setConfirmPassword
(
""
);
setConfirmPassword
(
""
);
toast
.
success
(
"Đặt lại mật khẩu thành công"
);
toast
.
success
(
"Đặt lại mật khẩu thành công"
);
}
catch
(
error
)
{
}
catch
(
error
)
{
setResetError
(
getAuthErrorMessage
(
error
,
"Không thể đặt lại mật khẩu. Vui lòng thử lại."
));
setResetError
(
getAuthErrorMessage
(
error
,
"Không thể đặt lại mật khẩu. Vui lòng thử lại."
,
),
);
}
finally
{
}
finally
{
setResetLoading
(
false
);
setResetLoading
(
false
);
}
}
...
@@ -442,7 +514,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -442,7 +514,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<
AuthShell
mode=
{
mode
}
>
<
AuthShell
mode=
{
mode
}
>
{
mode
===
"login"
?
(
{
mode
===
"login"
?
(
<
form
className=
"space-y-5"
onSubmit=
{
handleLogin
}
>
<
form
className=
"space-y-5"
onSubmit=
{
handleLogin
}
>
{
loginError
?
<
InlineMessage
type=
"error"
message=
{
loginError
}
/>
:
null
}
{
loginError
?
(
<
InlineMessage
type=
"error"
message=
{
loginError
}
/>
)
:
null
}
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"admin-email"
className=
"text-gray-700"
>
<
Label
htmlFor=
"admin-email"
className=
"text-gray-700"
>
...
@@ -500,7 +574,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -500,7 +574,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</
Button
>
</
Button
>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
loginLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
loginLoading
}
>
{
loginLoading
?
(
{
loginLoading
?
(
<>
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
@@ -515,8 +593,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -515,8 +593,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
{
mode
===
"forgot"
?
(
{
mode
===
"forgot"
?
(
<
form
className=
"space-y-5"
onSubmit=
{
handleSendOtp
}
>
<
form
className=
"space-y-5"
onSubmit=
{
handleSendOtp
}
>
{
resetError
?
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
:
null
}
{
resetError
?
(
{
resetMessage
?
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
:
null
}
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
)
:
null
}
{
resetMessage
?
(
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
)
:
null
}
<
div
className=
"space-y-2"
>
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"forgot-email"
className=
"text-gray-700"
>
<
Label
htmlFor=
"forgot-email"
className=
"text-gray-700"
>
...
@@ -537,7 +619,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -537,7 +619,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</
div
>
</
div
>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
{
resetLoading
?
(
<>
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
@@ -569,7 +655,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -569,7 +655,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
[
"done"
,
"Hoàn tất"
],
[
"done"
,
"Hoàn tất"
],
].
map
(([
step
,
label
])
=>
{
].
map
(([
step
,
label
])
=>
{
const
stepIndex
=
[
"verify"
,
"password"
,
"done"
].
indexOf
(
step
);
const
stepIndex
=
[
"verify"
,
"password"
,
"done"
].
indexOf
(
step
);
const
currentIndex
=
[
"verify"
,
"password"
,
"done"
].
indexOf
(
resetStep
);
const
currentIndex
=
[
"verify"
,
"password"
,
"done"
].
indexOf
(
resetStep
,
);
const
active
=
currentIndex
>=
stepIndex
;
const
active
=
currentIndex
>=
stepIndex
;
return
(
return
(
...
@@ -587,8 +675,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -587,8 +675,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
})
}
})
}
</
div
>
</
div
>
{
resetError
?
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
:
null
}
{
resetError
?
(
{
resetMessage
?
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
:
null
}
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
)
:
null
}
{
resetMessage
?
(
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
)
:
null
}
{
resetStep
===
"verify"
?
(
{
resetStep
===
"verify"
?
(
<
form
className=
"space-y-5"
onSubmit=
{
handleVerifyOtp
}
>
<
form
className=
"space-y-5"
onSubmit=
{
handleVerifyOtp
}
>
...
@@ -601,13 +693,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -601,13 +693,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
inputMode=
"numeric"
inputMode=
"numeric"
autoComplete=
"one-time-code"
autoComplete=
"one-time-code"
value=
{
otp
}
value=
{
otp
}
onChange=
{
(
event
)
=>
setOtp
(
event
.
target
.
value
.
replace
(
/
\D
/g
,
""
).
slice
(
0
,
6
))
}
onChange=
{
(
event
)
=>
setOtp
(
event
.
target
.
value
.
replace
(
/
\D
/g
,
""
).
slice
(
0
,
6
))
}
placeholder=
"Nhập 6 chữ số"
placeholder=
"Nhập 6 chữ số"
className=
{
`${authFieldClassName} text-center text-lg font-semibold tracking-[0.35em]`
}
className=
{
`${authFieldClassName} text-center text-lg font-semibold tracking-[0.35em]`
}
required
required
/>
/>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
{
resetLoading
?
(
<>
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
@@ -656,7 +754,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -656,7 +754,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
/>
/>
</
div
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
{
resetLoading
?
(
<>
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
@@ -673,12 +775,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
...
@@ -673,12 +775,19 @@ 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 d? du?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
>
</
div
>
</
div
>
<
Button
type=
"button"
className=
{
authButtonClassName
}
onClick=
{
switchToLogin
}
>
<
Button
type=
"button"
className=
{
authButtonClassName
}
onClick=
{
switchToLogin
}
>
Đăng nhập ngay
Đăng nhập ngay
</
Button
>
</
Button
>
</
div
>
</
div
>
...
...
src/components/shared/admin-sidebar.tsx
View file @
91a2bc40
'use client'
;
"use client"
;
import
React
from
'react'
;
import
React
from
"react"
;
import
Image
from
'next/image'
;
import
Image
from
"next/image"
;
import
Link
from
'next/link'
;
import
Link
from
"next/link"
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
usePathname
}
from
"next/navigation"
;
import
{
import
{
ChevronDown
,
ChevronDown
,
Globe
,
Globe
,
...
@@ -14,12 +14,22 @@ import {
...
@@ -14,12 +14,22 @@ import {
Settings
,
Settings
,
Sparkles
,
Sparkles
,
Tags
,
Tags
,
Users
,
Video
,
Video
,
}
from
'lucide-react'
;
}
from
"lucide-react"
;
import
logo
from
'@/assets/VCCI-HCM-logo-VN-2025.png'
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
getLogo
}
from
"@/api/endpoints/logo"
;
import
{
cn
}
from
'@/lib/utils'
;
import
type
{
Logo
}
from
"@/api/models/logo"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
type
LogoListEnvelope
=
{
data
?:
{
responseData
?:
{
rows
?:
Logo
[];
};
};
};
import
{
useSidebarStore
}
from
"@/hooks/use-admin-sidebar"
;
import
{
cn
}
from
"@/lib/utils"
;
type
NavChild
=
{
name
:
string
;
href
:
string
};
type
NavChild
=
{
name
:
string
;
href
:
string
};
type
NavItem
=
{
type
NavItem
=
{
...
@@ -30,11 +40,11 @@ type NavItem = {
...
@@ -30,11 +40,11 @@ type NavItem = {
};
};
const
navigation
:
NavItem
[]
=
[
const
navigation
:
NavItem
[]
=
[
{
name
:
'Cấu hình chung'
,
href
:
'/admin/base-config'
,
icon
:
Settings
},
{
name
:
"Cấu hình chung"
,
href
:
"/admin/base-config"
,
icon
:
Settings
},
{
name
:
'Cấu hình danh mục'
,
href
:
'/admin/header-config'
,
icon
:
Layers
},
{
name
:
"Cấu hình danh mục"
,
href
:
"/admin/header-config"
,
icon
:
Layers
},
{
name
:
'Quản lý bài viết'
,
href
:
'/admin/news'
,
icon
:
Newspaper
},
{
name
:
"Quản lý bài viết"
,
href
:
"/admin/news"
,
icon
:
Newspaper
},
{
name
:
'Quản lý tag tìm kiếm'
,
href
:
'/admin/tags'
,
icon
:
Tags
},
{
name
:
"Quản lý tag tìm kiếm"
,
href
:
"/admin/tags"
,
icon
:
Tags
},
{
name
:
'Quản lý video'
,
href
:
'/admin/videos'
,
icon
:
Video
},
{
name
:
"Quản lý video"
,
href
:
"/admin/videos"
,
icon
:
Video
},
// {
// {
// name: 'Quản lý hội viên',
// name: 'Quản lý hội viên',
// icon: Users,
// icon: Users,
...
@@ -62,25 +72,40 @@ const navigation: NavItem[] = [
...
@@ -62,25 +72,40 @@ const navigation: NavItem[] = [
// },
// },
// ],
// ],
// },
// },
{
name
:
'Quản lý Email đăng ký'
,
href
:
'/admin/contact-management/newsletter-emails'
,
icon
:
Mail
},
{
{
name
:
'Quản lý ảnh'
,
href
:
'/admin/media'
,
icon
:
ImagePlus
},
name
:
"Quản lý Email đăng ký"
,
href
:
"/admin/contact-management/newsletter-emails"
,
icon
:
Mail
,
},
{
name
:
"Quản lý ảnh"
,
href
:
"/admin/media"
,
icon
:
ImagePlus
},
];
];
const
membersReservedSegments
=
new
Set
([
'fields'
,
'regions'
]);
const
membersReservedSegments
=
new
Set
([
"fields"
,
"regions"
]);
export
function
AdminSidebar
()
{
export
function
AdminSidebar
()
{
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
{
close
,
isOpen
}
=
useSidebarStore
();
const
{
close
,
isOpen
}
=
useSidebarStore
();
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>>
({});
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>
>
({});
const
{
data
:
logoData
}
=
useQuery
({
queryKey
:
[
"logo"
,
{
page
:
1
,
pageSize
:
1
,
sortOrder
:
"desc"
}],
queryFn
:
()
=>
getLogo
({
page
:
1
,
pageSize
:
1
,
sortOrder
:
"desc"
}),
select
:
(
response
)
=>
(
response
as
LogoListEnvelope
)?.
data
?.
responseData
?.
rows
?.[
0
],
});
const
isItemActive
=
React
.
useCallback
(
const
isItemActive
=
React
.
useCallback
(
(
href
:
string
)
=>
{
(
href
:
string
)
=>
{
if
(
href
===
'/admin/members'
)
{
if
(
href
===
"/admin/members"
)
{
if
(
pathname
===
href
)
return
true
;
if
(
pathname
===
href
)
return
true
;
if
(
!
pathname
.
startsWith
(
`
${
href
}
/`
))
return
false
;
if
(
!
pathname
.
startsWith
(
`
${
href
}
/`
))
return
false
;
const
nextSegment
=
pathname
.
slice
(
`
${
href
}
/`
.
length
).
split
(
'/'
)[
0
];
const
nextSegment
=
pathname
.
slice
(
`
${
href
}
/`
.
length
).
split
(
"/"
)[
0
];
return
Boolean
(
nextSegment
)
&&
!
membersReservedSegments
.
has
(
nextSegment
);
return
(
Boolean
(
nextSegment
)
&&
!
membersReservedSegments
.
has
(
nextSegment
)
);
}
}
return
pathname
===
href
||
pathname
.
startsWith
(
`
${
href
}
/`
);
return
pathname
===
href
||
pathname
.
startsWith
(
`
${
href
}
/`
);
...
@@ -88,7 +113,8 @@ export function AdminSidebar() {
...
@@ -88,7 +113,8 @@ export function AdminSidebar() {
[
pathname
],
[
pathname
],
);
);
const
isGroupActive
=
(
children
:
NavChild
[])
=>
children
.
some
((
child
)
=>
isItemActive
(
child
.
href
));
const
isGroupActive
=
(
children
:
NavChild
[])
=>
children
.
some
((
child
)
=>
isItemActive
(
child
.
href
));
const
toggleGroup
=
(
name
:
string
)
=>
const
toggleGroup
=
(
name
:
string
)
=>
setExpandedGroups
((
previous
)
=>
({
...
previous
,
[
name
]:
!
previous
[
name
]
}));
setExpandedGroups
((
previous
)
=>
({
...
previous
,
[
name
]:
!
previous
[
name
]
}));
...
@@ -100,32 +126,43 @@ export function AdminSidebar() {
...
@@ -100,32 +126,43 @@ export function AdminSidebar() {
return
(
return
(
<
aside
<
aside
className=
{
cn
(
className=
{
cn
(
'fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300'
,
"fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300"
,
isOpen
?
'w-72 translate-x-0 lg:w-72'
:
'-translate-x-full lg:w-24 lg:translate-x-0'
,
isOpen
?
"w-72 translate-x-0 lg:w-72"
:
"-translate-x-full lg:w-24 lg:translate-x-0"
,
)
}
)
}
>
>
<
div
className=
"flex h-full flex-col"
>
<
div
className=
"flex h-full flex-col"
>
<
div
className=
{
cn
(
'px-4 pb-4 pt-5'
,
!
isOpen
&&
'px-3'
)
}
>
<
div
className=
{
cn
(
"px-4 pb-4 pt-5"
,
!
isOpen
&&
"px-3"
)
}
>
<
Link
<
Link
href=
"/admin/base-config"
href=
"/admin/base-config"
onClick=
{
handleMobileNavigate
}
onClick=
{
handleMobileNavigate
}
className=
{
cn
(
className=
{
cn
(
'flex items-center backdrop-blur-sm'
,
"flex items-center backdrop-blur-sm"
,
isOpen
isOpen
?
'gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]'
?
"gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]"
:
'justify-center px-0 py-4'
,
:
"justify-center px-0 py-4"
,
)
}
)
}
>
>
<
div
className=
"flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm"
>
<
div
className=
"flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm"
>
<
Image
src=
{
logo
}
alt=
"VCCI HCM"
className=
"h-10 w-10 object-contain"
priority
/>
<
Image
src=
{
logoData
?.
logo_url
||
logo
}
alt=
{
logoData
?.
logo_name
||
"VCCI HCM"
}
width=
{
40
}
height=
{
40
}
className=
"h-10 w-10 object-contain"
priority
/>
</
div
>
</
div
>
{
isOpen
?
(
{
isOpen
?
(
<
div
className=
"min-w-0"
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-[13px] font-bold uppercase tracking-[0.22em] text-[#063e8e]"
>
<
div
className=
"truncate text-[13px] font-bold uppercase tracking-[0.22em] text-[#063e8e]"
>
VCCI News
{
logoData
?.
logo_name
||
"VCCI News"
}
</
div
>
<
div
className=
"mt-1 text-sm leading-5 text-slate-600"
>
Trang quản trị website
</
div
>
</
div
>
<
div
className=
"mt-1 text-sm leading-5 text-slate-600"
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
Link
>
</
Link
>
...
@@ -142,8 +179,8 @@ export function AdminSidebar() {
...
@@ -142,8 +179,8 @@ export function AdminSidebar() {
<
nav
<
nav
className=
{
cn
(
className=
{
cn
(
'scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2'
,
"scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2"
,
!
isOpen
&&
'px-3'
,
!
isOpen
&&
"px-3"
,
)
}
)
}
>
>
{
navigation
.
map
((
item
)
=>
{
{
navigation
.
map
((
item
)
=>
{
...
@@ -155,8 +192,10 @@ export function AdminSidebar() {
...
@@ -155,8 +192,10 @@ export function AdminSidebar() {
<
div
<
div
key=
{
item
.
name
}
key=
{
item
.
name
}
className=
{
cn
(
className=
{
cn
(
'rounded-[26px] border border-transparent transition-all duration-200'
,
"rounded-[26px] border border-transparent transition-all duration-200"
,
isOpen
&&
expanded
&&
'border-[#063e8e]/10 bg-white/70 p-2 shadow-sm'
,
isOpen
&&
expanded
&&
"border-[#063e8e]/10 bg-white/70 p-2 shadow-sm"
,
)
}
)
}
>
>
<
button
<
button
...
@@ -164,21 +203,25 @@ export function AdminSidebar() {
...
@@ -164,21 +203,25 @@ export function AdminSidebar() {
onClick=
{
()
=>
isOpen
&&
toggleGroup
(
item
.
name
)
}
onClick=
{
()
=>
isOpen
&&
toggleGroup
(
item
.
name
)
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
className=
{
cn
(
className=
{
cn
(
'flex w-full items-center rounded-2xl text-sm font-medium transition-all duration-200'
,
"flex w-full items-center rounded-2xl text-sm font-medium transition-all duration-200"
,
active
active
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
?
"bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
:
'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]'
,
:
"text-slate-700 hover:bg-white/85 hover:text-[#063e8e]"
,
isOpen
?
'gap-3 px-4 py-3.5'
:
'mx-auto h-14 w-14 justify-center p-0'
,
isOpen
?
"gap-3 px-4 py-3.5"
:
"mx-auto h-14 w-14 justify-center p-0"
,
)
}
)
}
>
>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
{
isOpen
?
(
{
isOpen
?
(
<>
<>
<
span
className=
"min-w-0 flex-1 text-left"
>
{
item
.
name
}
</
span
>
<
span
className=
"min-w-0 flex-1 text-left"
>
{
item
.
name
}
</
span
>
<
ChevronDown
<
ChevronDown
className=
{
cn
(
className=
{
cn
(
'h-4 w-4 shrink-0 transition-transform'
,
"h-4 w-4 shrink-0 transition-transform"
,
expanded
&&
'rotate-180'
,
expanded
&&
"rotate-180"
,
)
}
)
}
/>
/>
</>
</>
...
@@ -196,10 +239,10 @@ export function AdminSidebar() {
...
@@ -196,10 +239,10 @@ export function AdminSidebar() {
href=
{
child
.
href
}
href=
{
child
.
href
}
onClick=
{
handleMobileNavigate
}
onClick=
{
handleMobileNavigate
}
className=
{
cn
(
className=
{
cn
(
'group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all'
,
"group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all"
,
childActive
childActive
?
'bg-[#dbe8ff] font-semibold text-[#063e8e]'
?
"bg-[#dbe8ff] font-semibold text-[#063e8e]"
:
'text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]'
,
:
"text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]"
,
)
}
)
}
>
>
<
span
className=
"block"
>
{
child
.
name
}
</
span
>
<
span
className=
"block"
>
{
child
.
name
}
</
span
>
...
@@ -217,19 +260,23 @@ export function AdminSidebar() {
...
@@ -217,19 +260,23 @@ export function AdminSidebar() {
return
(
return
(
<
Link
<
Link
key=
{
item
.
name
}
key=
{
item
.
name
}
href=
{
item
.
href
||
'#'
}
href=
{
item
.
href
||
"#"
}
onClick=
{
handleMobileNavigate
}
onClick=
{
handleMobileNavigate
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
className=
{
cn
(
className=
{
cn
(
'flex items-center rounded-2xl text-sm font-medium transition-all duration-200'
,
"flex items-center rounded-2xl text-sm font-medium transition-all duration-200"
,
active
active
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
?
"bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
:
'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]'
,
:
"text-slate-700 hover:bg-white/85 hover:text-[#063e8e]"
,
isOpen
?
'gap-3 px-4 py-3.5'
:
'mx-auto h-14 w-14 justify-center p-0'
,
isOpen
?
"gap-3 px-4 py-3.5"
:
"mx-auto h-14 w-14 justify-center p-0"
,
)
}
)
}
>
>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
{
isOpen
?
<
span
className=
"min-w-0 flex-1"
>
{
item
.
name
}
</
span
>
:
null
}
{
isOpen
?
(
<
span
className=
"min-w-0 flex-1"
>
{
item
.
name
}
</
span
>
)
:
null
}
</
Link
>
</
Link
>
);
);
})
}
})
}
...
@@ -248,7 +295,9 @@ export function AdminSidebar() {
...
@@ -248,7 +295,9 @@ export function AdminSidebar() {
</
div
>
</
div
>
<
div
>
<
div
>
<
div
>
Về trang chủ
</
div
>
<
div
>
Về trang chủ
</
div
>
<
div
className=
"mt-0.5 text-xs font-medium text-slate-500"
>
Website công khai
</
div
>
<
div
className=
"mt-0.5 text-xs font-medium text-slate-500"
>
Website công khai
</
div
>
</
div
>
</
div
>
</
Link
>
</
Link
>
<
div
className=
"mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500"
>
<
div
className=
"mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500"
>
...
...
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