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
import
{
AxiosError
,
isAxiosError
}
from
'axios'
import
{
QueryClient
}
from
'@tanstack/react-query'
// App
// import router from '@/router'
import
{
handleAdminUnauthorized
}
from
'@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import
{
QueryData
}
from
'@/lib/types/base-api'
import
{
AxiosError
,
isAxiosError
}
from
'axios'
import
{
QueryClient
}
from
'@tanstack/react-query'
// App
// import router from '@/router'
import
{
handleAdminUnauthorized
}
from
'@/lib/auth/admin-auth'
// import useProfileStore from '@stores/profile'
import
{
QueryData
}
from
'@/lib/types/base-api'
// import { BASE_PATHS } from '@/constants/path'
// Constants
const
RETRY_COUNT
=
3
const
EXPIRED_TOKEN_ERROR
=
401
const
DENIED_PERMISSION_ERROR
=
403
const
INTERNAL_SERVER_ERROR
=
500
const
API_QUERY_STALE_TIME
=
2
*
60
*
1000
const
API_QUERY_GC_TIME
=
10
*
60
*
1000
const
RETRY_COUNT
=
3
const
EXPIRED_TOKEN_ERROR
=
401
const
DENIED_PERMISSION_ERROR
=
403
const
INTERNAL_SERVER_ERROR
=
500
const
API_QUERY_STALE_TIME
=
2
*
60
*
1000
const
API_QUERY_GC_TIME
=
10
*
60
*
1000
// Utils
// Handle check base retry logical
...
...
@@ -27,8 +27,10 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
// Expired token error
if
(
error
.
response
?.
status
===
EXPIRED_TOKEN_ERROR
)
{
handleUnAuthorizationError
()
return
false
if
(
typeof
window
!==
"undefined"
&&
window
.
location
.
pathname
.
startsWith
(
"/admin"
))
{
handleUnAuthorizationError
();
}
return
false
;
}
// Denied permission error
...
...
@@ -42,15 +44,15 @@ const handleCheckBaseRetryLogical = (failureCount: number, error: Error) => {
}
// Handle un authorization error
const
handleUnAuthorizationError
=
()
=>
{
void
handleAdminUnauthorized
()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
const
handleUnAuthorizationError
=
()
=>
{
void
handleAdminUnauthorized
()
// useProfileStore.getState().resetStore()
// const languageAwarePath = addLanguageToPath({
// path: BASE_PATHS.authSignIn
// })
// router.navigate('')
}
// Handle delay value
const
handleDelayRetry
=
(
failureCount
:
number
)
=>
failureCount
*
1000
+
Math
.
random
()
*
1000
...
...
@@ -58,13 +60,13 @@ const handleDelayRetry = (failureCount: number) => failureCount * 1000 + Math.ra
// Query client
export
const
queryClient
=
new
QueryClient
({
defaultOptions
:
{
queries
:
{
staleTime
:
API_QUERY_STALE_TIME
,
gcTime
:
API_QUERY_GC_TIME
,
refetchOnWindowFocus
:
false
,
refetchOnMount
:
false
,
refetchOnReconnect
:
false
,
placeholderData
:
(
previousData
:
unknown
)
=>
previousData
,
queries
:
{
staleTime
:
API_QUERY_STALE_TIME
,
gcTime
:
API_QUERY_GC_TIME
,
refetchOnWindowFocus
:
false
,
refetchOnMount
:
false
,
refetchOnReconnect
:
false
,
placeholderData
:
(
previousData
:
unknown
)
=>
previousData
,
retry
(
failureCount
,
error
)
{
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
{
Swiper
,
SwiperSlide
}
from
"swiper/react"
;
...
...
@@ -7,40 +7,98 @@ import { Swiper as SwiperType } from "swiper/types";
import
{
useRef
}
from
"react"
;
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
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
(
<
Swiper
modules=
{
[
Autoplay
]
}
autoplay=
{
{
delay
:
4000
,
disableOnInteraction
:
false
}
}
loop
loop
=
{
rows
.
length
>
1
}
slidesPerView=
{
1
}
onSwiper=
{
(
s
)
=>
(
swiperRef
.
current
=
s
)
}
className=
"w-full overflow-hidden"
>
<
SwiperSlide
>
<
ImageNext
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"
alt=
"Banner"
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
</
SwiperSlide
>
<
SwiperSlide
>
<
ImageNext
src=
"https://vcci-hcm.org.vn/wp-content/uploads/2022/07/Landscape-HCM_3-01.png"
alt=
"Banner"
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
</
SwiperSlide
>
{
rows
.
map
((
row
:
any
)
=>
(
<
SwiperSlide
key=
{
row
.
id
}
>
{
row
.
file_id
?
(
<
BannerSlideItem
fileId=
{
row
.
file_id
}
alt=
{
row
.
banner_name
||
"Banner"
}
/>
)
:
(
<
ImageNext
src=
"/img-error.png"
alt=
{
row
.
banner_name
||
"Banner"
}
width=
{
2560
}
height=
{
720
}
sizes=
"100vw"
className=
"w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] object-cover"
/>
)
}
</
SwiperSlide
>
))
}
</
Swiper
>
);
}
}
;
export default Banner;
src/app/(main)/_lib/layout/footer.tsx
View file @
91a2bc40
...
...
@@ -13,18 +13,61 @@ import {
Youtube
,
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
subscribeNewsletterEmail
}
from
"@/lib/api/newsletter-subscriptions"
;
import
{
getSiteInformation
}
from
"@/api/endpoints/site-information"
;
import
type
{
SiteInformationData
}
from
"@/api/models"
;
const
socialLinks
=
[
{
icon
:
<
Facebook
className=
"h-5 w-5"
/>,
link
:
"https://www.facebook.com/VCCIHCMC/"
},
{
icon
:
<
Twitter
className=
"h-5 w-5"
/>,
link
:
"https://twitter.com/VCCI_HCM"
},
{
icon
:
<
Youtube
className=
"h-5 w-5"
/>,
link
:
"https://www.youtube.com/user/VCCIHCMC"
},
type
ApiEnvelope
<
T
>
=
{
responseData
?:
T
;
data
?:
{
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"
/>,
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 = [
{
label
:
"Giới thiệu"
,
href
:
"/gioi-thieu"
}
,
{
label
:
"Hội viên"
,
href
:
"/danh-ba-hoi-vien"
}
,
...
...
@@ -32,7 +75,8 @@ const quickLinks = [
{
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()
{
const
[
email
,
setEmail
]
=
useState
(
""
);
...
...
@@ -42,6 +86,67 @@ function Footer() {
const
[
message
,
setMessage
]
=
useState
(
""
);
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
trimmedEmail
=
email
.
trim
();
let
hasError
=
false
;
...
...
@@ -75,7 +180,11 @@ function Footer() {
setAccepted
(
false
);
setMessage
(
"Đăng ký nhận thông tin thành công."
);
}
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
{
setSubmitting
(
false
);
}
...
...
@@ -144,48 +253,91 @@ function Footer() {
</
div
>
<
div
>
<
h2
className=
"client-footer-title uppercase"
>
Liên hệ
</
h2
>
<
h2
className=
"client-footer-title uppercase"
>
Liên hệ
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-5 space-y-4"
>
<
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
>
<
div
className=
"space-y-2.5 text-[15px] text-[#c7d8ff]"
>
<
div
className=
"flex items-start gap-3"
>
<
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
className=
"flex items-center gap-3"
>
<
Phone
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
span
>
+84 28 3932 6598
</
span
>
<
span
>
{
contactInfo
.
telephone
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
Printer
className=
"h-4 w-4 shrink-0 text-[#f7b500]"
/>
<
span
>
+84 28 3932 5472
</
span
>
<
span
>
{
contactInfo
.
fax
}
</
span
>
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
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
>
{
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
>
<
h2
className=
"client-footer-title uppercase"
>
Kết nối
</
h2
>
<
h2
className=
"client-footer-title uppercase"
>
Kết nối
</
h2
>
<
div
className=
"mt-2.5 h-[4px] w-[48px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-5 flex flex-wrap gap-3"
>
{
socialLinks
.
map
((
item
)
=>
(
<
a
key=
{
item
.
link
}
href=
{
item
.
link
}
key=
{
item
.
key
}
href=
{
item
.
url
}
target=
"_blank"
rel=
"noreferrer"
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";
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
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
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
{
useCustomClient
as
customClient
}
from
"@/api/mutator/custom-client"
;
import
type
{
Category
}
from
"@/api/models/category"
;
import
{
getCategoryFallbackResponse
}
from
"@/mockdata/categories"
;
...
...
@@ -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)
{
if
(
!
url
)
return
"#"
;
return
url
.
startsWith
(
"/"
)
?
url
:
`/${url}`
;
...
...
@@ -102,16 +170,63 @@ function Header() {
const
{
data
:
categoriesResponse
}
=
useQuery
({
queryKey
:
[
"header-categories"
],
queryFn
:
()
=>
useC
ustomClient
<
CategoryListResponse
>
(
c
ustomClient
<
CategoryListResponse
>
(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC"
,
).
catch
(()
=>
getCategoryFallbackResponse
()),
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
(
()
=>
buildHeaderMenuTree
(
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
(()
=>
{
const
handleScroll
=
()
=>
{
...
...
@@ -167,12 +282,13 @@ function Header() {
<
div
className=
"flex items-center gap-4"
>
<
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"
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
encoded
=
encodeURIComponent
(
value
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
}
...
...
@@ -180,50 +296,34 @@ function Header() {
/>
<
div
className=
"flex items-center gap-2"
>
<
a
href=
"https://www.facebook.com/VCCIHCMC/"
target=
"_blank"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
<
Facebook
size=
{
12
}
fill=
"currentColor"
/>
</
a
>
<
a
href=
"https://twitter.com/VCCI_HCM"
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
>
{
socialLinks
.
map
((
item
)
=>
(
<
a
key=
{
item
.
key
}
href=
{
item
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
{
item
.
icon
}
</
a
>
))
}
</
div
>
</
div
>
</
div
>
</
div
>
<
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"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
<
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]"
>
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto w-[108px] object-contain"
src=
{
logo
}
alt=
"VCCI-HCM"
src=
{
currentLogo
?.
logo_url
||
logo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
/>
</
Link
>
...
...
@@ -248,7 +348,7 @@ function Header() {
</
div
>
</
nav
>
</
div
>
<
button
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"
...
...
@@ -260,7 +360,7 @@ function Header() {
</
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
? "pointer-events-auto translate-y-0 opacity-100"
: "pointer-events-none -translate-y-2 opacity-0"
...
...
@@ -268,11 +368,17 @@ function Header() {
>
<
div
className=
"flex h-full flex-col overflow-hidden"
>
<
div
className=
"sticky top-0 z-10 flex h-[78px] shrink-0 items-center justify-between border-b border-slate-100 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto w-[108px] object-contain"
src=
{
logo
}
alt=
"VCCI-HCM"
src=
{
currentLogo
?.
logo_url
||
logo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
/>
</
Link
>
...
...
@@ -292,7 +398,8 @@ function Header() {
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
encoded
=
encodeURIComponent
(
value
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
setToggleMenu
(
false
);
...
...
@@ -302,7 +409,10 @@ function Header() {
<
div
className=
"pb-6"
>
{
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
href=
{
category
.
url
||
"#"
}
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";
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
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
{
Dialog
,
DialogContent
,
...
...
@@ -43,11 +49,22 @@ import {
postSiteInformationBranches
,
putSiteInformation
,
}
from
"@/api/endpoints/site-information"
;
import
{
deleteLogoId
,
postLogo
,
putLogoId
}
from
"@/api/endpoints/logo"
;
import
{
deleteBannerId
,
getBanner
,
postBanner
,
putBannerId
}
from
"@/api/endpoints/banner"
;
import
{
deleteLogoId
,
getLogo
,
postLogo
,
putLogoId
,
}
from
"@/api/endpoints/logo"
;
import
{
deleteBannerId
,
getBanner
,
postBanner
,
putBannerId
,
}
from
"@/api/endpoints/banner"
;
import
type
{
Banner
,
BannerMutate
,
Logo
,
SiteInformationBranch
,
SiteInformationBranchMutate
,
SiteInformationData
,
...
...
@@ -87,6 +104,10 @@ type LogoMediaItem = AdminMediaItem & {
logoId
?:
string
;
};
type
LogoListResponse
=
{
rows
?:
Logo
[];
};
type
PageEnvelope
<
T
>
=
{
rows
?:
T
[];
count
?:
number
;
...
...
@@ -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
;
}
...
...
@@ -121,7 +145,9 @@ function getEnvelopeData<T>(payload: unknown): T | undefined {
return
root
.
responseData
??
root
.
data
?.
responseData
;
}
function
mapApiBranchToConfig
(
branch
:
SiteInformationBranch
):
BaseConfigBranchItem
{
function
mapApiBranchToConfig
(
branch
:
SiteInformationBranch
,
):
BaseConfigBranchItem
{
return
{
id
:
branch
.
id
??
createBaseConfigItemId
(
"branch"
),
branchName
:
branch
.
branch_name
??
""
,
...
...
@@ -146,12 +172,16 @@ function mapConfigBranchToApi(
email
:
branch
.
email
.
trim
()
||
null
,
fax
:
branch
.
fax
.
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
,
};
}
function
mapApiSocialToConfig
(
social
:
SiteInformationSocialLink
):
BaseConfigSocialItem
{
function
mapApiSocialToConfig
(
social
:
SiteInformationSocialLink
,
):
BaseConfigSocialItem
{
return
{
id
:
social
.
id
,
label
:
social
.
label
,
...
...
@@ -161,7 +191,9 @@ function mapApiSocialToConfig(social: SiteInformationSocialLink): BaseConfigSoci
};
}
function
mapConfigSocialToApi
(
social
:
BaseConfigSocialItem
):
SiteInformationSocialMutate
{
function
mapConfigSocialToApi
(
social
:
BaseConfigSocialItem
,
):
SiteInformationSocialMutate
{
return
{
url
:
social
.
url
.
trim
()
||
null
,
sort_order
:
social
.
sortOrder
,
...
...
@@ -190,13 +222,10 @@ function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
};
}
function
map
SiteLogoToConfig
(
siteInformation
:
SiteInformationData
):
{
function
map
ApiLogoToConfig
(
logo
:
Logo
):
{
logo
:
BaseConfigLogoItem
|
null
;
media
:
LogoMediaItem
|
null
;
}
|
null
{
const
logo
=
siteInformation
.
logo
;
if
(
!
logo
)
return
null
;
const
media
:
LogoMediaItem
=
{
id
:
logo
.
file_id
,
logoId
:
logo
.
id
,
...
...
@@ -224,8 +253,9 @@ function mapSiteLogoToConfig(siteInformation: SiteInformationData): {
function
applySiteInformationToConfig
(
baseConfig
:
BaseConfigData
,
siteInformation
:
SiteInformationData
,
logo
?:
Logo
|
null
,
):
BaseConfigData
{
const
logoConfig
=
mapSiteLogoToConfig
(
siteInformation
)
;
const
logoConfig
=
logo
?
mapApiLogoToConfig
(
logo
)
:
null
;
return
{
...
baseConfig
,
...
...
@@ -266,7 +296,12 @@ function ConfigItemPreview({
>
<
div
className=
"relative aspect-[16/10] overflow-hidden bg-[#eef4ff]"
>
{
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"
>
Chưa chọn hình ảnh
...
...
@@ -274,7 +309,9 @@ function ConfigItemPreview({
)
}
</
div
>
<
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
>
</
button
>
...
...
@@ -302,7 +339,10 @@ function ConfigItemDialog({
title
:
string
;
description
:
string
;
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;
onSubmit: () =
>
void;
})
{
...
...
@@ -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"
>
<
DialogHeader
>
<
div
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogDescription
className=
"mt-2 text-sm text-gray-600"
>
{
description
}
</
DialogDescription
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogDescription
className=
"mt-2 text-sm text-gray-600"
>
{
description
}
</
DialogDescription
>
</
div
>
</
DialogHeader
>
...
...
@@ -323,21 +367,28 @@ function ConfigItemDialog({
<
Input
value=
{
form
.
name
}
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
}
/>
</
div
>
{
mode
===
"banner"
?
(
<
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
type=
"number"
min=
{
1
}
max=
{
60
}
value=
{
form
.
displayTimeSeconds
}
onChange=
{
(
event
)
=>
onChange
(
"displayTimeSeconds"
,
Number
(
event
.
target
.
value
||
1
))
onChange
(
"displayTimeSeconds"
,
Number
(
event
.
target
.
value
||
1
),
)
}
className=
{
fieldClassName
}
/>
...
...
@@ -351,7 +402,9 @@ function ConfigItemDialog({
type=
"number"
min=
{
1
}
value=
{
form
.
sortOrder
}
onChange=
{
(
event
)
=>
onChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
onChange=
{
(
event
)
=>
onChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -388,13 +441,18 @@ function ConfigItemDialog({
{
mode
===
"banner"
?
(
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3"
>
<
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái 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
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái 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
>
<
Switch
checked=
{
form
.
isActive
}
onCheckedChange=
{
(
value
)
=>
onChange
(
"isActive"
,
value
)
}
/>
<
Switch
checked=
{
form
.
isActive
}
onCheckedChange=
{
(
value
)
=>
onChange
(
"isActive"
,
value
)
}
/>
</
div
>
)
:
null
}
</
div
>
...
...
@@ -446,14 +504,18 @@ function BranchCard({
}`
}
>
<
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"
>
{
branch
.
address
||
"Chưa cập nhật địa chỉ"
}
</
div
>
</
button
>
<
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
type=
"button"
variant=
"ghost"
...
...
@@ -473,12 +535,16 @@ export default function AdminBaseConfigPage() {
const
[
mediaItems
,
setMediaItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
currentBannerIndex
,
setCurrentBannerIndex
]
=
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
[
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
[
itemForm
,
setItemForm
]
=
React
.
useState
<
ConfigItemForm
>
(
emptyItemForm
());
const
[
itemForm
,
setItemForm
]
=
React
.
useState
<
ConfigItemForm
>
(
emptyItemForm
());
const
[
imagePickerOpen
,
setImagePickerOpen
]
=
React
.
useState
(
false
);
const
[
savingItem
,
setSavingItem
]
=
React
.
useState
(
false
);
const
[
savingWebsiteInfo
,
setSavingWebsiteInfo
]
=
React
.
useState
(
false
);
...
...
@@ -497,24 +563,63 @@ export default function AdminBaseConfigPage() {
const
loadSiteInformation
=
async
()
=>
{
try
{
const
response
=
await
getSiteInformation
();
const
siteInformation
=
getEnvelopeData
<
SiteInformationData
>
(
response
);
const
[
siteInformationResponse
,
logoResponse
]
=
await
Promise
.
all
([
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
(
logoConfig
?.
media
)
{
const
logoMedia
=
logoConfig
.
media
;
setMediaItems
((
previous
)
=>
{
const
nextMap
=
new
Map
(
previous
.
map
((
entry
)
=>
[
entry
.
id
,
entry
]));
nextMap
.
set
(
logoMedia
.
id
,
logoMedia
);
return
Array
.
from
(
nextMap
.
values
());
});
if
(
currentLogo
)
{
const
logoConfig
=
mapApiLogoToConfig
(
currentLogo
);
if
(
logoConfig
?.
media
)
{
const
logoMedia
=
logoConfig
.
media
;
setMediaItems
((
previous
)
=>
{
const
nextMap
=
new
Map
(
previous
.
map
((
entry
)
=>
[
entry
.
id
,
entry
]),
);
nextMap
.
set
(
logoMedia
.
id
,
logoMedia
);
return
Array
.
from
(
nextMap
.
values
());
});
}
}
setConfig
((
previous
)
=>
applySiteInformationToConfig
(
previous
??
baseConfig
,
siteInformation
),
);
if
(
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
)
{
console
.
error
(
error
);
if
(
mounted
)
{
...
...
@@ -600,10 +705,14 @@ export default function AdminBaseConfigPage() {
const
currentLogo
=
config
?.
logo
??
null
;
const
currentBanner
=
sortedBanners
[
currentBannerIndex
]
??
null
;
const
currentBranch
=
(
currentBranchId
?
sortedBranches
.
find
((
branch
)
=>
branch
.
id
===
currentBranchId
)
:
null
)
??
(
currentBranchId
?
sortedBranches
.
find
((
branch
)
=>
branch
.
id
===
currentBranchId
)
:
null
)
??
sortedBranches
[
currentBranchIndex
]
??
null
;
const
currentLogoMedia
=
currentLogo
?
resolveMediaItem
(
mediaMap
,
currentLogo
.
imageId
)
:
null
;
const
currentLogoMedia
=
currentLogo
?
resolveMediaItem
(
mediaMap
,
currentLogo
.
imageId
)
:
null
;
const
currentBannerMedia
=
currentBanner
?
resolveMediaItem
(
mediaMap
,
currentBanner
.
imageId
)
:
null
;
...
...
@@ -619,19 +728,24 @@ export default function AdminBaseConfigPage() {
setEditingItemId
(
null
);
setItemForm
({
...
emptyItemForm
(),
sortOrder
:
mode
===
"banner"
?
(
config
?
config
.
banners
.
length
+
1
:
1
)
:
1
,
sortOrder
:
mode
===
"banner"
?
(
config
?
config
.
banners
.
length
+
1
:
1
)
:
1
,
});
setItemDialogOpen
(
true
);
};
const
openEditDialog
=
(
mode
:
ConfigItemMode
,
item
:
BaseConfigLogoItem
|
BaseConfigBannerItem
)
=>
{
const
openEditDialog
=
(
mode
:
ConfigItemMode
,
item
:
BaseConfigLogoItem
|
BaseConfigBannerItem
,
)
=>
{
setItemDialogMode
(
mode
);
setEditingItemId
(
item
.
id
);
setItemForm
({
name
:
item
.
name
,
imageId
:
item
.
imageId
,
isActive
:
item
.
isActive
,
displayTimeSeconds
:
"displayTimeSeconds"
in
item
?
item
.
displayTimeSeconds
:
5
,
displayTimeSeconds
:
"displayTimeSeconds"
in
item
?
item
.
displayTimeSeconds
:
5
,
sortOrder
:
"sortOrder"
in
item
?
item
.
sortOrder
:
1
,
});
setItemDialogOpen
(
true
);
...
...
@@ -669,7 +783,8 @@ export default function AdminBaseConfigPage() {
logo_url
:
selectedMedia
?.
url
??
null
,
file_id
:
itemForm
.
imageId
,
});
const
savedLogo
=
getEnvelopeData
<
NonNullable
<
SiteInformationData
[
"logo"
]
>>
(
response
);
const
savedLogo
=
getEnvelopeData
<
NonNullable
<
SiteInformationData
[
"logo"
]
>>
(
response
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
logo
=
{
...
...
@@ -705,7 +820,9 @@ export default function AdminBaseConfigPage() {
?
await
putBannerId
(
editingItemId
,
mapConfigBannerToApi
(
bannerDraft
))
:
await
postBanner
(
mapConfigBannerToApi
(
bannerDraft
));
const
savedBanner
=
getEnvelopeData
<
Banner
>
(
response
);
const
nextBanner
=
savedBanner
?
mapApiBannerToConfig
(
savedBanner
)
:
bannerDraft
;
const
nextBanner
=
savedBanner
?
mapApiBannerToConfig
(
savedBanner
)
:
bannerDraft
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
editingItemId
)
{
...
...
@@ -731,38 +848,34 @@ export default function AdminBaseConfigPage() {
const
nextConfig
=
cloneBaseConfigData
(
config
!
);
if
(
editingItemId
)
{
nextConfig
.
banners
=
nextConfig
.
banners
.
map
((
item
)
=>
item
.
id
===
editingItemId
?
{
...
item
,
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
}
:
item
,
);
nextConfig
.
banners
=
nextConfig
.
banners
.
map
((
item
)
=>
item
.
id
===
editingItemId
?
{
...
item
,
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
}
:
item
,
);
}
else
{
nextConfig
.
banners
.
push
({
id
:
createBaseConfigItemId
(
"banner"
),
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
});
setCurrentBannerIndex
(
Math
.
max
(
nextConfig
.
banners
.
length
-
1
,
0
));
nextConfig
.
banners
.
push
({
id
:
createBaseConfigItemId
(
"banner"
),
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
});
setCurrentBannerIndex
(
Math
.
max
(
nextConfig
.
banners
.
length
-
1
,
0
));
}
saveConfig
(
nextConfig
);
setSavingItem
(
false
);
setItemDialogOpen
(
false
);
toast
.
success
(
false
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
,
);
toast
.
success
(
false
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
);
};
const
handleDeleteItem
=
async
()
=>
{
...
...
@@ -771,28 +884,32 @@ export default function AdminBaseConfigPage() {
try
{
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
deleteTarget
.
mode
===
"logo"
)
{
try
{
await
deleteLogoId
(
deleteTarget
.
id
);
nextConfig
.
logo
=
null
;
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không thể xóa cấu hình logo"
);
setDeleteTarget
(
null
);
return
;
if
(
deleteTarget
.
mode
===
"logo"
)
{
try
{
await
deleteLogoId
(
deleteTarget
.
id
);
nextConfig
.
logo
=
null
;
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không thể xóa cấu hình logo"
);
setDeleteTarget
(
null
);
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
);
toast
.
success
(
"Đã xóa cấu hình"
);
}
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
{
setDeleteTarget
(
null
);
}
...
...
@@ -809,7 +926,9 @@ export default function AdminBaseConfigPage() {
?
{
...
previous
,
branches
:
previous
.
branches
.
map
((
branch
)
=>
branch
.
id
===
currentBranch
.
id
?
{
...
branch
,
[
key
]:
value
}
:
branch
,
branch
.
id
===
currentBranch
.
id
?
{
...
branch
,
[
key
]:
value
}
:
branch
,
),
}
:
previous
,
...
...
@@ -836,10 +955,15 @@ export default function AdminBaseConfigPage() {
if
(
!
config
)
return
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
((
branch
)
=>
branch
.
id
!==
branchId
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
(
(
branch
)
=>
branch
.
id
!==
branchId
,
);
saveConfig
(
nextConfig
);
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"
);
}
;
...
...
@@ -901,10 +1025,15 @@ export default function AdminBaseConfigPage() {
await
deleteSiteInformationBranchesId
(
branchId
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
((
branch
)
=>
branch
.
id
!==
branchId
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
(
(
branch
)
=>
branch
.
id
!==
branchId
,
);
setConfig
(
nextConfig
);
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
);
toast
.
success
(
"Đã xóa chi nhánh"
);
...
...
@@ -923,7 +1052,10 @@ export default function AdminBaseConfigPage() {
try
{
await
Promise
.
all
(
sortBaseConfigBranches
(
config
.
branches
).
map
((
branch
,
index
)
=>
patchSiteInformationBranchesId
(
branch
.
id
,
mapConfigBranchToApi
(
branch
,
index
)),
patchSiteInformationBranchesId
(
branch
.
id
,
mapConfigBranchToApi
(
branch
,
index
),
),
),
);
setConfig
(
config
);
...
...
@@ -936,8 +1068,13 @@ export default function AdminBaseConfigPage() {
}
}
;
const handleWebsiteInfoChange = (key: "websiteName" | "websiteLink", value: string) =
>
{
setConfig
((
previous
)
=>
(
previous
?
{
...
previous
,
[
key
]:
value
}
:
previous
));
const handleWebsiteInfoChange = (
key: "websiteName" | "websiteLink",
value: string,
) =
>
{
setConfig
((
previous
)
=>
previous
?
{
...
previous
,
[
key
]:
value
}
:
previous
,
);
}
;
const handleSaveWebsiteInfo = async () =
>
{
...
...
@@ -998,7 +1135,10 @@ export default function AdminBaseConfigPage() {
try
{
await
Promise
.
all
(
config
.
socials
.
map
((
social
)
=>
patchSiteInformationSocialsId
(
social
.
id
,
mapConfigSocialToApi
(
social
)),
patchSiteInformationSocialsId
(
social
.
id
,
mapConfigSocialToApi
(
social
),
),
),
);
setConfig
(
config
);
...
...
@@ -1021,33 +1161,37 @@ export default function AdminBaseConfigPage() {
return (
<
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"
>
<
TabsList
className=
"h-auto min-w-max rounded-2xl bg-[#eaf2ff] p-1.5"
>
<
TabsTrigger
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]"
>
Nhận diện thương hiệu
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Banner trang chủ
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Thông tin liên hệ
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Mạng xã hội
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Nhận diện thương hiệu
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Banner trang chủ
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Thông tin liên hệ
</
TabsTrigger
>
<
TabsTrigger
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]"
>
Mạng xã hội
</
TabsTrigger
>
</
TabsList
>
</
div
>
...
...
@@ -1056,7 +1200,9 @@ export default function AdminBaseConfigPage() {
<
CardHeader
className=
"pb-5"
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
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"
>
Quản lý logo hiển thị trên website.
</
CardDescription
>
...
...
@@ -1133,13 +1279,20 @@ export default function AdminBaseConfigPage() {
<
div
className=
"text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]"
>
Logo website
</
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
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Tên website
</
Label
>
<
Input
value=
{
config
.
websiteName
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteName"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteName"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1147,7 +1300,12 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Link website
</
Label
>
<
Input
value=
{
config
.
websiteLink
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteLink"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteLink"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1156,7 +1314,10 @@ export default function AdminBaseConfigPage() {
Trạng thái
</
div
>
<
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"
}
</
Badge
>
</
div
>
...
...
@@ -1168,7 +1329,9 @@ export default function AdminBaseConfigPage() {
className=
"w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<
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
>
</
div
>
)
:
(
...
...
@@ -1187,9 +1350,12 @@ export default function AdminBaseConfigPage() {
<
CardHeader
className=
"pb-5"
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
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"
>
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
>
</
div
>
...
...
@@ -1264,7 +1430,9 @@ export default function AdminBaseConfigPage() {
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
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
}
...
...
@@ -1278,7 +1446,9 @@ export default function AdminBaseConfigPage() {
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
setCurrentBannerIndex
((
previous
)
=>
sortedBanners
.
length
===
0
?
0
:
(
previous
+
1
)
%
sortedBanners
.
length
,
sortedBanners
.
length
===
0
?
0
:
(
previous
+
1
)
%
sortedBanners
.
length
,
)
}
disabled=
{
sortedBanners
.
length
<=
1
}
...
...
@@ -1304,14 +1474,20 @@ export default function AdminBaseConfigPage() {
{
currentBanner
?
(
<
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=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Tên banner
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
name
}
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Tên banner
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
name
}
</
div
>
</
div
>
<
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"
>
Thứ tự hiển thị
</
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
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
...
...
@@ -1322,9 +1498,14 @@ export default function AdminBaseConfigPage() {
</
div
>
</
div
>
<
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"
>
<
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"
}
</
Badge
>
</
div
>
...
...
@@ -1340,7 +1521,9 @@ export default function AdminBaseConfigPage() {
<
CardHeader
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
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"
>
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</
CardDescription
>
...
...
@@ -1396,7 +1579,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Tên chi nhánh
</
Label
>
<
Input
value=
{
currentBranch
.
branchName
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"branchName"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"branchName"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1405,7 +1590,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Địa chỉ
</
Label
>
<
Textarea
value=
{
currentBranch
.
address
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"address"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"address"
,
event
.
target
.
value
)
}
className=
{
`${fieldClassName} min-h-[110px]`
}
/>
</
div
>
...
...
@@ -1415,7 +1602,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Hotline
</
Label
>
<
Input
value=
{
currentBranch
.
hotline
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"hotline"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"hotline"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1423,7 +1612,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Email
</
Label
>
<
Input
value=
{
currentBranch
.
email
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"email"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"email"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1434,7 +1625,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Fax
</
Label
>
<
Input
value=
{
currentBranch
.
fax
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"fax"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"fax"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1442,7 +1635,12 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Google Maps
</
Label
>
<
Input
value=
{
currentBranch
.
mapsEmbedUrl
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"mapsEmbedUrl"
,
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"mapsEmbedUrl"
,
event
.
target
.
value
,
)
}
className=
{
fieldClassName
}
/>
</
div
>
...
...
@@ -1456,28 +1654,38 @@ export default function AdminBaseConfigPage() {
min=
{
1
}
value=
{
currentBranch
.
sortOrder
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
handleBranchChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
),
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3"
>
<
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"
>
{
currentBranch
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
{
currentBranch
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
div
>
</
div
>
<
Switch
checked=
{
currentBranch
.
isVisible
}
onCheckedChange=
{
(
value
)
=>
handleBranchChange
(
"isVisible"
,
value
)
}
onCheckedChange=
{
(
value
)
=>
handleBranchChange
(
"isVisible"
,
value
)
}
/>
</
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"
>
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
>
...
...
@@ -1490,7 +1698,9 @@ export default function AdminBaseConfigPage() {
<
CardHeader
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
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"
>
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
</
CardDescription
>
...
...
@@ -1519,11 +1729,17 @@ export default function AdminBaseConfigPage() {
<
Checkbox
checked=
{
item
.
isVisible
}
onCheckedChange=
{
(
checked
)
=>
handleSocialChange
(
item
.
id
,
"isVisible"
,
checked
===
true
)
handleSocialChange
(
item
.
id
,
"isVisible"
,
checked
===
true
,
)
}
/>
<
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"
>
{
item
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
div
>
...
...
@@ -1534,7 +1750,9 @@ export default function AdminBaseConfigPage() {
<
Label
className=
"text-gray-700"
>
Link URL
</
Label
>
<
Input
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}
...
`
}
className=
{
fieldClassName
}
/>
...
...
@@ -1547,7 +1765,11 @@ export default function AdminBaseConfigPage() {
min=
{
1
}
value=
{
item
.
sortOrder
}
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
handleSocialChange
(
item
.
id
,
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
),
)
}
className=
{
fieldClassName
}
/>
...
...
@@ -1581,7 +1803,9 @@ export default function AdminBaseConfigPage() {
:
"Thiết lập banner hiển thị cho trang chủ."
}
onOpenChange=
{
setItemDialogOpen
}
onChange=
{
(
key
,
value
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}))
}
onChange=
{
(
key
,
value
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}))
}
onPickImage=
{
()
=>
setImagePickerOpen
(
true
)
}
onSubmit=
{
handleSubmitItem
}
/>
...
...
@@ -1605,7 +1829,8 @@ export default function AdminBaseConfigPage() {
title=
"Xóa cấu hình"
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.
</>
}
...
...
src/app/admin/login/page.tsx
View file @
91a2bc40
...
...
@@ -14,6 +14,9 @@ import {
ShieldCheck
,
}
from
"lucide-react"
;
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
{
Checkbox
}
from
"@/components/ui/checkbox"
;
import
{
Input
}
from
"@/components/ui/input"
;
...
...
@@ -35,6 +38,14 @@ type ApiEnvelope<T = unknown> = {
message_en
?:
string
|
null
;
};
type
LogoListEnvelope
=
{
data
?:
{
responseData
?:
{
rows
?:
Logo
[];
};
};
};
type
VerifyOtpPayload
=
{
reset_token
?:
string
;
expires_in
?:
number
;
...
...
@@ -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]"
;
function
normalizeRedirectPath
(
redirect
:
string
|
null
)
{
if
(
!
redirect
||
!
redirect
.
startsWith
(
"/admin"
)
||
redirect
===
"/admin/login"
)
{
if
(
!
redirect
||
!
redirect
.
startsWith
(
"/admin"
)
||
redirect
===
"/admin/login"
)
{
return
DEFAULT_REDIRECT
;
}
...
...
@@ -95,7 +110,8 @@ async function postAuthJson<TResponse, TBody>(path: string, body: TBody) {
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
)
{
throw
{
...
...
@@ -115,6 +131,13 @@ function AuthShell({
mode
:
AuthMode
;
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
=
mode
===
"login"
?
"Đăng nhập quản trị"
...
...
@@ -139,13 +162,22 @@ function AuthShell({
<
div
>
<
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"
>
<
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
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
className=
"mt-1 text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
...
...
@@ -158,16 +190,21 @@ function AuthShell({
Quản lý nội dung với giao diện riêng cho admin.
</
h1
>
<
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,
media và các dữ liệu vận hành.
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,
media và các dữ liệu vận hành.
</
p
>
</
div
>
</
div
>
<
div
className=
"grid grid-cols-3 gap-3"
>
{
[
"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
className=
"text-sm font-semibold text-[#063e8e]"
>
{
item
}
</
div
>
<
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
>
))
}
...
...
@@ -179,13 +216,22 @@ function AuthShell({
<
div
className=
"mx-auto w-full max-w-md"
>
<
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]"
>
<
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
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
className=
"text-sm text-gray-700"
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
...
...
@@ -193,8 +239,12 @@ function AuthShell({
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"
>
<
LockKeyhole
className=
"h-6 w-6"
/>
</
div
>
<
h2
className=
"mt-5 text-2xl font-bold text-gray-900"
>
{
title
}
</
h2
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
{
description
}
</
p
>
<
h2
className=
"mt-5 text-2xl font-bold text-gray-900"
>
{
title
}
</
h2
>
<
p
className=
"mt-2 text-sm leading-6 text-gray-700"
>
{
description
}
</
p
>
</
div
>
{
children
}
...
...
@@ -263,7 +313,9 @@ function InlineMessage({
}
>
<
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
>
</
div
>
</
div
>
...
...
@@ -314,12 +366,18 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
try
{
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"
);
router
.
replace
(
redirect
);
}
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
{
setLoginLoading
(
false
);
}
...
...
@@ -332,15 +390,20 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
try
{
await
postAuthJson
<
ApiEnvelope
,
{
email
:
string
}
>
(
"/auth/forgot-password/send-otp"
,
{
email
:
email
.
trim
(),
});
await
postAuthJson
<
ApiEnvelope
,
{
email
:
string
}
>
(
"/auth/forgot-password/send-otp"
,
{
email
:
email
.
trim
(),
},
);
setResetStep
(
"verify"
);
setMode
(
"reset"
);
setResetMessage
(
"M? OTP d? du?c g?i d?n email qu?n tr?."
);
}
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
{
setResetLoading
(
false
);
}
...
...
@@ -353,13 +416,13 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
try
{
const
response
=
await
postAuthJson
<
ApiEnvelope
<
VerifyOtpPayload
>
,
{
email
:
string
;
otp
:
string
}
>
(
"/auth/forgot-password/verify-otp"
,
{
email
:
email
.
trim
(),
otp
:
otp
.
trim
(),
}
,
);
const
response
=
await
postAuthJson
<
ApiEnvelope
<
VerifyOtpPayload
>
,
{
email
:
string
;
otp
:
string
}
>
(
"/auth/forgot-password/verify-otp"
,
{
email
:
email
.
trim
(),
otp
:
otp
.
trim
()
,
}
);
const
payload
=
getResponseData
<
VerifyOtpPayload
>
(
response
);
if
(
!
payload
?.
reset_token
)
{
...
...
@@ -370,7 +433,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetStep
(
"password"
);
setResetMessage
(
"OTP hợp lệ. Bạn có thể tạo mật khẩu mới."
);
}
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
{
setResetLoading
(
false
);
}
...
...
@@ -394,22 +459,29 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setResetLoading
(
true
);
try
{
await
postAuthJson
<
ApiEnvelope
,
{
reset_token
:
string
;
new_password
:
string
}
>
(
"/auth/forgot-password/reset"
,
{
reset_token
:
resetToken
,
new_password
:
newPassword
,
}
,
);
await
postAuthJson
<
ApiEnvelope
,
{
reset_token
:
string
;
new_password
:
string
}
>
(
"/auth/forgot-password/reset"
,
{
reset_token
:
resetToken
,
new_password
:
newPassword
,
}
);
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
(
""
);
setNewPassword
(
""
);
setConfirmPassword
(
""
);
toast
.
success
(
"Đặt lại mật khẩu thành công"
);
}
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
{
setResetLoading
(
false
);
}
...
...
@@ -442,7 +514,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<
AuthShell
mode=
{
mode
}
>
{
mode
===
"login"
?
(
<
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"
>
<
Label
htmlFor=
"admin-email"
className=
"text-gray-700"
>
...
...
@@ -500,7 +574,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</
Button
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
loginLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
loginLoading
}
>
{
loginLoading
?
(
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
...
@@ -515,8 +593,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
{
mode
===
"forgot"
?
(
<
form
className=
"space-y-5"
onSubmit=
{
handleSendOtp
}
>
{
resetError
?
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
:
null
}
{
resetMessage
?
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
:
null
}
{
resetError
?
(
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
)
:
null
}
{
resetMessage
?
(
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
)
:
null
}
<
div
className=
"space-y-2"
>
<
Label
htmlFor=
"forgot-email"
className=
"text-gray-700"
>
...
...
@@ -537,7 +619,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</
div
>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
...
@@ -569,7 +655,9 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
[
"done"
,
"Hoàn tất"
],
].
map
(([
step
,
label
])
=>
{
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
;
return
(
...
...
@@ -587,8 +675,12 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
})
}
</
div
>
{
resetError
?
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
:
null
}
{
resetMessage
?
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
:
null
}
{
resetError
?
(
<
InlineMessage
type=
"error"
message=
{
resetError
}
/>
)
:
null
}
{
resetMessage
?
(
<
InlineMessage
type=
"success"
message=
{
resetMessage
}
/>
)
:
null
}
{
resetStep
===
"verify"
?
(
<
form
className=
"space-y-5"
onSubmit=
{
handleVerifyOtp
}
>
...
...
@@ -601,13 +693,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
inputMode=
"numeric"
autoComplete=
"one-time-code"
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ố"
className=
{
`${authFieldClassName} text-center text-lg font-semibold tracking-[0.35em]`
}
required
/>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
...
@@ -656,7 +754,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
/>
</
div
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
<
Button
type=
"submit"
className=
{
authButtonClassName
}
disabled=
{
resetLoading
}
>
{
resetLoading
?
(
<>
<
LoaderCircle
className=
"h-4 w-4 animate-spin"
/>
...
...
@@ -673,12 +775,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
<
div
className=
"space-y-5"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center"
>
<
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"
>
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
>
</
div
>
<
Button
type=
"button"
className=
{
authButtonClassName
}
onClick=
{
switchToLogin
}
>
<
Button
type=
"button"
className=
{
authButtonClassName
}
onClick=
{
switchToLogin
}
>
Đăng nhập ngay
</
Button
>
</
div
>
...
...
src/components/shared/admin-sidebar.tsx
View file @
91a2bc40
'use client'
;
"use client"
;
import
React
from
'react'
;
import
Image
from
'next/image'
;
import
Link
from
'next/link'
;
import
{
usePathname
}
from
'next/navigation'
;
import
React
from
"react"
;
import
Image
from
"next/image"
;
import
Link
from
"next/link"
;
import
{
usePathname
}
from
"next/navigation"
;
import
{
ChevronDown
,
Globe
,
...
...
@@ -14,12 +14,22 @@ import {
Settings
,
Sparkles
,
Tags
,
Users
,
Video
,
}
from
'lucide-react'
;
import
logo
from
'@/assets/VCCI-HCM-logo-VN-2025.png'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
cn
}
from
'@/lib/utils'
;
}
from
"lucide-react"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
getLogo
}
from
"@/api/endpoints/logo"
;
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
NavItem
=
{
...
...
@@ -30,11 +40,11 @@ type NavItem = {
};
const
navigation
:
NavItem
[]
=
[
{
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
:
'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ý video'
,
href
:
'/admin/videos'
,
icon
:
Video
},
{
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
:
"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ý video"
,
href
:
"/admin/videos"
,
icon
:
Video
},
// {
// name: 'Quản lý hội viên',
// icon: Users,
...
...
@@ -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
()
{
const
pathname
=
usePathname
();
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
(
(
href
:
string
)
=>
{
if
(
href
===
'/admin/members'
)
{
if
(
href
===
"/admin/members"
)
{
if
(
pathname
===
href
)
return
true
;
if
(
!
pathname
.
startsWith
(
`
${
href
}
/`
))
return
false
;
const
nextSegment
=
pathname
.
slice
(
`
${
href
}
/`
.
length
).
split
(
'/'
)[
0
];
return
Boolean
(
nextSegment
)
&&
!
membersReservedSegments
.
has
(
nextSegment
);
const
nextSegment
=
pathname
.
slice
(
`
${
href
}
/`
.
length
).
split
(
"/"
)[
0
];
return
(
Boolean
(
nextSegment
)
&&
!
membersReservedSegments
.
has
(
nextSegment
)
);
}
return
pathname
===
href
||
pathname
.
startsWith
(
`
${
href
}
/`
);
...
...
@@ -88,7 +113,8 @@ export function AdminSidebar() {
[
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
)
=>
setExpandedGroups
((
previous
)
=>
({
...
previous
,
[
name
]:
!
previous
[
name
]
}));
...
...
@@ -100,32 +126,43 @@ export function AdminSidebar() {
return
(
<
aside
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'
,
isOpen
?
'w-72 translate-x-0 lg:w-72'
:
'-translate-x-full lg:w-24 lg:translate-x-0'
,
"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"
,
)
}
>
<
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
href=
"/admin/base-config"
onClick=
{
handleMobileNavigate
}
className=
{
cn
(
'flex items-center backdrop-blur-sm'
,
"flex items-center backdrop-blur-sm"
,
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)]'
:
'justify-center px-0 py-4'
,
?
"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"
,
)
}
>
<
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
>
{
isOpen
?
(
<
div
className=
"min-w-0"
>
<
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
className=
"mt-1 text-sm leading-5 text-slate-600"
>
Trang quản trị website
</
div
>
</
div
>
)
:
null
}
</
Link
>
...
...
@@ -142,8 +179,8 @@ export function AdminSidebar() {
<
nav
className=
{
cn
(
'scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2'
,
!
isOpen
&&
'px-3'
,
"scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2"
,
!
isOpen
&&
"px-3"
,
)
}
>
{
navigation
.
map
((
item
)
=>
{
...
...
@@ -155,8 +192,10 @@ export function AdminSidebar() {
<
div
key=
{
item
.
name
}
className=
{
cn
(
'rounded-[26px] border border-transparent transition-all duration-200'
,
isOpen
&&
expanded
&&
'border-[#063e8e]/10 bg-white/70 p-2 shadow-sm'
,
"rounded-[26px] border border-transparent transition-all duration-200"
,
isOpen
&&
expanded
&&
"border-[#063e8e]/10 bg-white/70 p-2 shadow-sm"
,
)
}
>
<
button
...
...
@@ -164,21 +203,25 @@ export function AdminSidebar() {
onClick=
{
()
=>
isOpen
&&
toggleGroup
(
item
.
name
)
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
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
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
:
'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'
,
?
"bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
:
"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"
,
)
}
>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
{
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
className=
{
cn
(
'h-4 w-4 shrink-0 transition-transform'
,
expanded
&&
'rotate-180'
,
"h-4 w-4 shrink-0 transition-transform"
,
expanded
&&
"rotate-180"
,
)
}
/>
</>
...
...
@@ -196,10 +239,10 @@ export function AdminSidebar() {
href=
{
child
.
href
}
onClick=
{
handleMobileNavigate
}
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
?
'bg-[#dbe8ff] font-semibold text-[#063e8e]'
:
'text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]'
,
?
"bg-[#dbe8ff] font-semibold text-[#063e8e]"
:
"text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]"
,
)
}
>
<
span
className=
"block"
>
{
child
.
name
}
</
span
>
...
...
@@ -217,19 +260,23 @@ export function AdminSidebar() {
return
(
<
Link
key=
{
item
.
name
}
href=
{
item
.
href
||
'#'
}
href=
{
item
.
href
||
"#"
}
onClick=
{
handleMobileNavigate
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
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
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
:
'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'
,
?
"bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
:
"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"
,
)
}
>
<
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
>
);
})
}
...
...
@@ -248,7 +295,9 @@ export function AdminSidebar() {
</
div
>
<
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
>
</
Link
>
<
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