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
b6c2eab7
Commit
b6c2eab7
authored
May 15, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix header
parent
67f22283
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
346 additions
and
190 deletions
+346
-190
index.tsx
src/app/(main)/(home)/components/featured-news/index.tsx
+3
-3
ArticlePage.tsx
src/app/(main)/[...slug]/templates/ArticlePage.tsx
+1
-1
header.tsx
src/app/(main)/_lib/layout/header.tsx
+141
-70
layout.tsx
src/app/(main)/layout.tsx
+1
-1
index.tsx
src/components/base/list-category/index.tsx
+2
-2
index.tsx
src/components/base/menu-category/index.tsx
+65
-70
index.tsx
src/components/base/menu-item/index.tsx
+99
-29
hover-card.tsx
src/components/ui/hover-card.tsx
+12
-10
_components.css
src/styles/_components.css
+22
-4
No files found.
src/app/(main)/(home)/components/featured-news/index.tsx
View file @
b6c2eab7
...
@@ -57,7 +57,7 @@ function FeaturedNews() {
...
@@ -57,7 +57,7 @@ function FeaturedNews() {
{
primaryItem
.
categories
[
0
]?.
name
||
"Tin nổi bật"
}
{
primaryItem
.
categories
[
0
]?.
name
||
"Tin nổi bật"
}
</
span
>
</
span
>
<
h3
className=
"max-w-3xl text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]"
>
<
h3
className=
"max-w-3xl
line-clamp-3
text-[20px] font-bold leading-[1.28] text-white md:text-[28px] xl:text-[32px]"
>
{
primaryItem
.
title
}
{
primaryItem
.
title
}
</
h3
>
</
h3
>
...
@@ -70,8 +70,8 @@ function FeaturedNews() {
...
@@ -70,8 +70,8 @@ function FeaturedNews() {
</
div
>
</
div
>
</
Link
>
</
Link
>
)
:
(
)
:
(
<
div
className=
"relative min-h-[260px] overflow-hidden rounded-
[24px]
bg-[#e9eef8] shadow-[0_18px_38px_rgba(28,52,120,0.12)] md:min-h-[320px] xl:min-h-[350px]"
>
<
div
className=
"relative min-h-[260px] overflow-hidden rounded-
3xl
bg-[#e9eef8] shadow-[0_18px_38px_rgba(28,52,120,0.12)] md:min-h-[320px] xl:min-h-[350px]"
>
<
div
className=
"flex h-full min-h-[260px] flex-col justify-end p-4 md:min-h-
[320px]
md:p-5 xl:min-h-[350px]"
>
<
div
className=
"flex h-full min-h-[260px] flex-col justify-end p-4 md:min-h-
80
md:p-5 xl:min-h-[350px]"
>
<
span
className=
"mb-2 h-8 w-28 rounded-[10px] bg-white/80"
/>
<
span
className=
"mb-2 h-8 w-28 rounded-[10px] bg-white/80"
/>
<
div
className=
"h-8 w-3/4 rounded bg-white/90 md:h-10"
/>
<
div
className=
"h-8 w-3/4 rounded bg-white/90 md:h-10"
/>
<
div
className=
"mt-2 h-5 w-28 rounded bg-white/70"
/>
<
div
className=
"mt-2 h-5 w-28 rounded bg-white/70"
/>
...
...
src/app/(main)/[...slug]/templates/ArticlePage.tsx
View file @
b6c2eab7
...
@@ -93,7 +93,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
...
@@ -93,7 +93,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
{
categoryMenu
.
length
>
0
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
<
br
/>
}
{
categoryMenu
.
length
>
0
?
<
ListCategory
categories=
{
categoryMenu
}
/>
:
<
br
/>
}
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
div
className=
"grid grid-cols-1 lg:grid-cols-3 gap-6"
>
<
main
className=
"lg:col-span-2 bg-
background
"
>
<
main
className=
"lg:col-span-2 bg-
white
"
>
<
div
className=
"pb-5 overflow-hidden"
>
<
div
className=
"pb-5 overflow-hidden"
>
{
paginatedPosts
.
length
?
(
{
paginatedPosts
.
length
?
(
paginatedPosts
.
map
((
item
)
=>
{
paginatedPosts
.
map
((
item
)
=>
{
...
...
src/app/(main)/_lib/layout/header.tsx
View file @
b6c2eab7
"use client"
;
"use client"
;
import
React
,
{
useState
}
from
"react"
;
import
React
,
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
Menu
,
X
,
Facebook
,
Linkedin
,
Twitter
,
Youtube
}
from
"lucide-react"
;
import
{
Facebook
,
Linkedin
,
Menu
,
Twitter
,
X
,
Youtube
}
from
"lucide-react"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
{
useQuery
}
from
"@tanstack/react-query"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
Image
from
"next/image"
;
import
Image
from
"next/image"
;
import
MenuItem
from
"@/components/base/menu-item"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
import
logo
from
"@/assets/VCCI-HCM-logo-VN-2025.png"
;
import
MenuItem
from
"@/components/base/menu-item"
;
import
{
useCustomClient
}
from
"@/api/mutator/custom-client"
;
import
{
useCustomClient
}
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"
;
...
@@ -81,155 +82,225 @@ function buildHeaderMenuTree(rows?: Category[]) {
...
@@ -81,155 +82,225 @@ function buildHeaderMenuTree(rows?: Category[]) {
}
}
function
Header
()
{
function
Header
()
{
const
[
toggleMenu
,
setToggleMenu
]
=
useState
<
boolean
>
(
false
);
const
[
toggleMenu
,
setToggleMenu
]
=
useState
(
false
);
const
[
isTopBarHidden
,
setIsTopBarHidden
]
=
useState
(
false
);
const
router
=
useRouter
();
const
router
=
useRouter
();
const
handleDesktopMenuWheel
=
useCallback
(
(
event
:
React
.
WheelEvent
<
HTMLElement
>
)
=>
{
const
element
=
event
.
currentTarget
;
if
(
element
.
scrollWidth
<=
element
.
clientWidth
)
return
;
if
(
Math
.
abs
(
event
.
deltaY
)
<=
Math
.
abs
(
event
.
deltaX
))
return
;
event
.
preventDefault
();
element
.
scrollLeft
+=
event
.
deltaY
;
},
[],
);
const
{
data
:
categoriesResponse
}
=
useQuery
({
const
{
data
:
categoriesResponse
}
=
useQuery
({
queryKey
:
[
"header-categories"
],
queryKey
:
[
"header-categories"
],
queryFn
:
()
=>
queryFn
:
()
=>
useCustomClient
<
CategoryListResponse
>
(
useCustomClient
<
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
,
});
});
const
menuItems
=
React
.
useMemo
(
const
menuItems
=
useMemo
(
()
=>
buildHeaderMenuTree
(
categoriesResponse
?.
responseData
?.
rows
),
()
=>
buildHeaderMenuTree
(
categoriesResponse
?.
responseData
?.
rows
),
[
categoriesResponse
?.
responseData
?.
rows
],
[
categoriesResponse
?.
responseData
?.
rows
],
);
);
useEffect
(()
=>
{
const
handleScroll
=
()
=>
{
setIsTopBarHidden
(
window
.
scrollY
>
0
);
};
handleScroll
();
window
.
addEventListener
(
"scroll"
,
handleScroll
,
{
passive
:
true
});
return
()
=>
{
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
};
},
[]);
return
(
return
(
<>
<
header
className=
"sticky top-0 z-50 shadow-[0_1px_0_rgba(15,23,42,0.05)]"
>
<
div
className=
"sticky top-0 w-full h-14 hidden lg:flex items-center justify-center bg-[#063e8e]"
>
<
div
<
div
className=
"container w-full px-4 flex items-center justify-between"
>
className=
{
`hidden w-full items-center justify-center overflow-hidden bg-[#25439a] ${
<
div
className=
"flex items-center gap-3"
>
isTopBarHidden ? "lg:hidden" : "h-10 lg:flex"
<
div
className=
"w-35 h-9 bg-[#e8c518] flex items-center justify-center border-3 rounded-sm border-[#647792]"
>
}`
}
>
<
div
className=
"mx-auto flex h-full w-full max-w-[1460px] items-center justify-between gap-6 px-6 xl:px-8"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex h-7 items-center justify-center rounded-[4px] bg-[#f2b500] px-4 shadow-[inset_0_-1px_0_rgba(0,0,0,0.15)]"
>
<
Link
<
Link
className=
"
font-bold text-[14px] text-primary hover:text-white transition
"
className=
"
text-[13px] font-semibold leading-none text-[#15357a] transition hover:opacity-85
"
href=
"https://vccihcm.vn/dang-ky"
href=
"https://vccihcm.vn/dang-ky"
>
>
Đăng Ký Hội Viên
{
"
\
u0110
\
u0103ng K
\
u00fd H
\
u1ed9i Vi
\
u00ean"
}
</
Link
>
</
Link
>
</
div
>
</
div
>
<
Link
<
Link
className=
"px-3 py-
2 text-[14px] text-white
hover:opacity-80"
className=
"px-3 py-
1 text-[13px] font-medium text-white transition
hover:opacity-80"
href=
"/site-map"
href=
"/site-map"
>
>
Sitemap
Sitemap
</
Link
>
</
Link
>
<
Link
<
Link
className=
"px-3 py-
2 text-[14px] text-white
hover:opacity-80"
className=
"px-3 py-
1 text-[13px] font-medium text-white transition
hover:opacity-80"
href=
"https://vccihcm.vn/lien-he"
href=
"https://vccihcm.vn/lien-he"
>
>
Liên hệ
{
"Li
\
u00ean h
\
u1ec7"
}
</
Link
>
</
Link
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-
8
"
>
<
div
className=
"flex items-center gap-
4
"
>
<
input
<
input
className=
"
bg-white h-10 rounded-sm outline-none px-4 w-64 placeholder:text-sm
"
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]
"
type=
"text"
type=
"text"
placeholder=
"Tìm kiếm"
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
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`
);
}
}
}
}
}
}
/>
/>
<
div
className=
"flex gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
a
<
a
href=
"https://www.facebook.com/VCCIHCMC/"
href=
"https://www.facebook.com/VCCIHCMC/"
target=
"_blank"
target=
"_blank"
className=
"bg-white size-7 rounded-full flex items-center justify-center text-[#063e8e] hover:opacity-80 transition"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
>
<
Facebook
size=
{
1
6
}
/>
<
Facebook
size=
{
1
2
}
fill=
"currentColor"
/>
</
a
>
</
a
>
<
a
<
a
href=
"https://twitter.com/VCCI_HCM"
href=
"https://twitter.com/VCCI_HCM"
target=
"_blank"
target=
"_blank"
className=
"bg-white size-7 rounded-full flex items-center justify-center text-[#063e8e] hover:opacity-80 transition"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
>
<
Twitter
size=
{
1
6
}
/>
<
Twitter
size=
{
1
2
}
fill=
"currentColor"
/>
</
a
>
</
a
>
<
a
<
a
href=
"https://www.youtube.com/user/VCCIHCMC"
href=
"https://www.youtube.com/user/VCCIHCMC"
target=
"_blank"
target=
"_blank"
className=
"bg-white size-7 rounded-full flex items-center justify-center text-[#063e8e] hover:opacity-80 transition"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
>
<
Youtube
size=
{
1
6
}
/>
<
Youtube
size=
{
1
2
}
fill=
"currentColor"
/>
</
a
>
</
a
>
<
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"
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"
target=
"_blank"
className=
"bg-white size-7 rounded-full flex items-center justify-center text-[#063e8e] hover:opacity-80 transition"
rel=
"noreferrer"
className=
"flex size-[22px] items-center justify-center rounded-full bg-white text-[#2f57ff] transition hover:opacity-80"
>
>
<
Linkedin
size=
{
1
6
}
/>
<
Linkedin
size=
{
1
2
}
fill=
"currentColor"
/>
</
a
>
</
a
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"sticky top-0 z-50 bg-[#ededed] shadow-md py-2"
>
<
div
className=
"border-b border-slate-200 bg-white"
>
<
div
className=
"container m-auto"
>
<
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=
"w-full flex justify-between items-center"
>
<
Link
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
{
/* Logo */
}
<
Image
<
Link
href=
"/"
>
className=
"h-auto w-[108px] object-contain"
<
Image
src=
{
logo
}
className=
"w-[140px] object-contain"
alt=
"VCCI-HCM"
src=
{
logo
}
priority
alt=
"VCCI-HCM"
/>
/>
</
Link
>
</
Link
>
{
/* Desktop Menu */
}
<
div
className=
"hidden min-w-0 flex-1 justify-end pl-6 lg:flex xl:pl-10"
>
<
nav
className=
"hidden lg:flex items-center"
>
<
nav
{
menuItems
.
map
((
category
)
=>
(
className=
"header-menu-scroll min-w-0 max-w-full overflow-x-auto overflow-y-hidden"
<
MenuItem
onWheel=
{
handleDesktopMenuWheel
}
key=
{
category
.
id
}
>
title=
{
category
.
name
}
<
div
className=
"flex w-max min-w-full items-center justify-end gap-4 whitespace-nowrap pr-1 xl:gap-6"
>
link=
{
category
.
url
}
{
menuItems
.
map
((
category
)
=>
(
items=
{
[
<
MenuItem
...
category
.
children
.
map
((
child
)
=>
({
key=
{
category
.
id
}
title=
{
category
.
name
}
link=
{
category
.
url
}
items=
{
category
.
children
.
map
((
child
)
=>
({
title
:
child
.
name
,
title
:
child
.
name
,
link
:
child
.
url
,
link
:
child
.
url
,
}))
,
}))
}
]
}
/>
/>
))
}
))
}
</
div
>
</
nav
>
</
nav
>
{
/* Mobile Button */
}
<
button
onClick=
{
()
=>
setToggleMenu
((
prev
)
=>
!
prev
)
}
className=
"lg:hidden h-10 p-2 bg-[#063e8e] text-white rounded-sm mr-5"
>
{
toggleMenu
?
<
X
size=
{
20
}
/>
:
<
Menu
size=
{
20
}
/>
}
</
button
>
</
div
>
</
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"
aria
-
label=
{
"M
\
u1edf menu"
}
>
{
toggleMenu
?
<
X
size=
{
18
}
/>
:
<
Menu
size=
{
18
}
/>
}
</
button
>
</
div
>
</
div
>
<
div
className=
{
`overflow-hidden border-t border-slate-200 bg-white transition-all duration-300 lg:hidden ${
toggleMenu ? "max-h-[520px] opacity-100" : "max-h-0 opacity-0"
}`
}
>
<
div
className=
"px-4 py-3"
>
<
input
className=
"h-11 w-full rounded-md border border-slate-200 px-4 text-sm outline-none placeholder:text-slate-400 focus:border-[#2f57ff]"
type=
"text"
placeholder=
{
"T
\
u00ecm ki
\
u1ebfm"
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
)
{
const
value
=
(
e
.
currentTarget
as
HTMLInputElement
).
value
||
""
;
const
encoded
=
encodeURIComponent
(
value
);
router
.
push
(
`/search?q=${encoded}&page=1`
);
setToggleMenu
(
false
);
}
}
}
/>
</
div
>
</
div
>
{
/* Mobile Menu */
}
<
div
className=
"pb-3"
>
<
div
className=
{
`lg:hidden bg-white shadow-lg transition-all duration-300 overflow-hidden ${toggleMenu ? "max-h-[500px] opacity-100" : "max-h-0 opacity-0"
}`
}
>
{
menuItems
.
map
((
category
)
=>
(
{
menuItems
.
map
((
category
)
=>
(
<
div
key=
{
category
.
id
}
className=
"border-
b border-gray-20
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 p
y-3 text-center hover:bg-[#124588] hover:text-white text-[16px] font-medium
"
className=
"block p
x-5 py-3 text-[15px] font-medium text-slate-700 transition hover:bg-slate-50 hover:text-[#2f57ff]
"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
>
{
category
.
name
}
{
category
.
name
}
</
Link
>
</
Link
>
{
category
.
children
.
length
>
0
?
(
<
div
className=
"pb-2 pl-8 pr-5"
>
{
category
.
children
.
map
((
child
)
=>
(
<
Link
key=
{
child
.
id
}
href=
{
child
.
url
||
"#"
}
className=
"block py-2 text-sm text-slate-500 transition hover:text-[#2f57ff]"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
{
child
.
name
}
</
Link
>
))
}
</
div
>
)
:
null
}
</
div
>
</
div
>
))
}
))
}
</
div
>
</
div
>
</
div
>
</
div
>
</>
</
header
>
);
);
}
}
...
...
src/app/(main)/layout.tsx
View file @
b6c2eab7
...
@@ -9,7 +9,7 @@ export default function Layout({
...
@@ -9,7 +9,7 @@ export default function Layout({
children
:
React
.
ReactNode
;
children
:
React
.
ReactNode
;
}
>
)
{
}
>
)
{
return
(
return
(
<
main
className=
"flex flex-col min-h-screen bg-
background
"
>
<
main
className=
"flex flex-col min-h-screen bg-
white
"
>
<
Header
/>
<
Header
/>
<
div
className=
"flex-1"
>
{
children
}
</
div
>
<
div
className=
"flex-1"
>
{
children
}
</
div
>
<
ScrollToTopButton
/>
<
ScrollToTopButton
/>
...
...
src/components/base/list-category/index.tsx
View file @
b6c2eab7
...
@@ -22,7 +22,7 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
...
@@ -22,7 +22,7 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
<
div
className=
"border-t border-gray-200 bg-white py-2"
>
<
div
className=
"border-t border-gray-200 bg-white py-2"
>
<
div
className=
"w-full px-4 sm:px-6 lg:px-8"
>
<
div
className=
"w-full px-4 sm:px-6 lg:px-8"
>
<
div
className=
"py-3"
>
<
div
className=
"py-3"
>
<
div
className=
"flex
flex-wrap items-center max-w-full overflow-x-auto
"
>
<
div
className=
"flex
max-w-full items-center gap-3 overflow-x-auto pb-1
"
>
{
categories
.
map
((
category
)
=>
{
{
categories
.
map
((
category
)
=>
{
const
href
=
resolveHref
(
category
);
const
href
=
resolveHref
(
category
);
const
menu
=
{
id
:
category
.
id
,
name
:
category
.
name
,
link
:
href
};
const
menu
=
{
id
:
category
.
id
,
name
:
category
.
name
,
link
:
href
};
...
@@ -30,7 +30,7 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
...
@@ -30,7 +30,7 @@ const ListCategory: React.FC<{ categories?: Category[] }> = ({ categories = [] }
return
(
return
(
<
div
key=
{
category
.
id
}
className=
"shrink-0"
>
<
div
key=
{
category
.
id
}
className=
"shrink-0"
>
<
MenuItem
menu=
{
menu
}
active=
{
active
}
/>
<
MenuItem
menu=
{
menu
}
active=
{
active
}
variant=
"secondary"
/>
</
div
>
</
div
>
);
);
})
}
})
}
...
...
src/components/base/menu-category/index.tsx
View file @
b6c2eab7
'use client'
'use client'
type
Menu
=
{
type
Menu
=
{
id
:
string
|
number
id
:
string
|
number
name
:
string
name
:
string
...
@@ -7,19 +8,22 @@ type Menu = {
...
@@ -7,19 +8,22 @@ type Menu = {
}
}
import
{
buttonVariants
}
from
'@components/ui/button'
import
{
buttonVariants
}
from
'@components/ui/button'
import
{
HoverCard
,
HoverCardContent
,
HoverCardTrigger
}
from
'@components/ui/hover-card'
import
{
cn
}
from
'@lib/utils'
import
{
cn
}
from
'@lib/utils'
import
{
useCallback
,
useMemo
}
from
'react'
import
{
HoverCard
,
HoverCardTrigger
,
HoverCardContent
}
from
'@components/ui/hover-card'
import
{
cva
}
from
'class-variance-authority'
import
{
usePathname
}
from
'next/navigation'
import
Link
from
'next/link'
import
Link
from
'next/link'
import
{
usePathname
}
from
'next/navigation'
import
{
useCallback
,
useMemo
}
from
'react'
export
function
MenuItem
(
props
:
{
variant
?:
'main'
|
'secondary'
;
menu
:
Menu
;
active
?:
boolean
})
{
export
function
MenuItem
(
props
:
{
variant
?:
'main'
|
'secondary'
;
menu
:
Menu
;
active
?:
boolean
})
{
const
{
menu
,
variant
=
'main'
,
active
}
=
props
const
{
menu
,
variant
=
'main'
,
active
}
=
props
const
pathname
=
usePathname
()
const
pathname
=
usePathname
()
const
isActive
=
pathname
.
startsWith
(
menu
.
link
??
''
);
const
normalizedLink
=
menu
.
link
&&
menu
.
link
!==
'#'
?
menu
.
link
:
'/'
const
hasChildren
=
Boolean
(
menu
.
children
?.
length
)
const
isRoot
=
normalizedLink
===
'/'
const
isActive
=
active
||
(
isRoot
?
pathname
===
'/'
:
pathname
.
startsWith
(
normalizedLink
))
const
linkId
=
useMemo
(()
=>
`trigger_
${
menu
.
id
}
`
,
[
menu
.
id
])
const
linkId
=
useMemo
(()
=>
`trigger_
${
menu
.
id
}
`
,
[
menu
.
id
])
const
hoverCardRef
=
useCallback
(
const
hoverCardRef
=
useCallback
(
(
element
:
HTMLDivElement
)
=>
{
(
element
:
HTMLDivElement
)
=>
{
if
(
!
element
)
return
if
(
!
element
)
return
...
@@ -28,76 +32,67 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
...
@@ -28,76 +32,67 @@ export function MenuItem(props: { variant?: 'main' | 'secondary'; menu: Menu; ac
[linkId]
[linkId]
)
)
return (
const trigger = (
<HoverCard openDelay={0} closeDelay={0}>
<Link
<HoverCardTrigger asChild>
aria-selected={isActive}
<Link
id={linkId}
aria-selected={active || isActive}
target={normalizedLink.startsWith('/') ? '_self' : '_blank'}
id={linkId}
href={normalizedLink}
target={(menu.link ?? '').startsWith('/') ? '_self' : '_blank'}
className={menuItemTriggerClass(variant)}
href={menu.link ?? '/'}
>
className={menuItemTriggerVariant({ variant })}
<span className="relative z-10 truncate">{menu.name}</span>
>
{variant === 'main' ? <span className="menu-item-underline" aria-hidden="true" /> : null}
{menu.name}
</Link>
</Link>
)
</HoverCardTrigger>
{menu.children && (
if (!hasChildren) {
<HoverCardContent ref={hoverCardRef} className={menuItemHoverBoxVariant({ variant })}>
return trigger
{menu.children.map((subMenu) => (
}
<Link key={subMenu.id} href={subMenu.link ?? '/'} className={menuItemChildVariant({ variant })}>
{subMenu.name}
return (
</Link>
<HoverCard openDelay={80} closeDelay={120}>
))}
<HoverCardTrigger asChild>{trigger}</HoverCardTrigger>
</HoverCardContent>
<HoverCardContent ref={hoverCardRef} className={menuItemHoverBoxVariant(variant)}>
)}
{menu.children?.map((subMenu) => (
<Link key={subMenu.id} href={subMenu.link ?? '/'} className={menuItemChildVariant(variant)}>
{subMenu.name}
</Link>
))}
</HoverCardContent>
</HoverCard>
</HoverCard>
)
)
}
}
const menuItemTriggerVariant = cva(
function menuItemTriggerClass(variant: 'main' | 'secondary') {
cn(buttonVariants({ variant: 'ghost' }), 'font-semibold focus-visible:ring-0 focus-visible:ring-offset-0 py-'),
if (variant === 'secondary') {
{
return cn(
variants: {
'inline-flex h-[36px] items-center justify-center rounded-full border border-[#d6dfeb] bg-white px-5 text-[13px] font-medium leading-none text-[#5f6b7d] shadow-none transition-colors duration-150',
variant: {
'hover:border-[#c5d2e3] hover:bg-[#f7faff] hover:text-[#1b5aa1]',
main: cn(
'aria-selected:border-[#16559d] aria-selected:bg-[#16559d] aria-selected:text-white',
'font-semibold text-[#363636] text-2xl hover:text-muted-foreground hover:bg-white py-3.5 px-5',
'focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
'aria-selected:text-muted-foreground'
)
),
secondary: cn(
'font-boldtext-primary border-t-2 border-t-transparent rounded-none',
'hover:text-primary/90',
'aria-selected:border-t-secondary aria-selected:bg-accent',
'aria-selected:bg-[#E9C826]'
)
}
},
defaultVariants: {
variant: 'main'
}
}
}
)
const menuItemHoverBoxVariant = cva('flex w-full flex-col gap-2 p-0', {
return cn(
variants: {
buttonVariants({ variant: 'ghost' }),
variant: {
'group relative inline-flex h-[60px] rounded-none border-b-2 border-transparent px-3 py-0 text-[15px] font-semibold text-slate-700 shadow-none transition-colors duration-150',
main: 'bg-secondary',
'hover:bg-transparent hover:text-[#2f57ff]',
secondary: 'bg-muted '
'aria-selected:bg-transparent aria-selected:text-[#2f57ff]',
}
'focus-visible:ring-0 focus-visible:ring-offset-0 xl:px-4'
},
)
defaultVariants: {
}
variant: 'main'
}
})
const menuItemChildVariant = cva(cn(buttonVariants({ variant: 'ghost' }), 'justify-start'), {
function menuItemHoverBoxVariant(variant: 'main' | 'secondary') {
variants: {
return cn(
variant: {
'mt-1 flex w-full min-w-[220px] flex-col gap-1 rounded-md border border-slate-200 bg-white p-2 shadow-[0_12px_30px_rgba(15,23,42,0.12)]',
main: 'text-secondary-foreground hover:text-muted-foreground hover:bg-secondary',
variant === 'secondary' ? 'bg-white' : ''
secondary: 'text-accent-foreground hover:text-primary/90 '
)
}
}
},
defaultVariants: {
function menuItemChildVariant(_variant: 'main' | 'secondary') {
variant: 'main'
return cn(
}
buttonVariants({ variant: 'ghost' }),
})
'h-10 justify-start rounded-md px-3 text-sm font-medium text-slate-600 transition-colors',
'hover:bg-slate-50 hover:text-[#2f57ff]'
)
}
src/components/base/menu-item/index.tsx
View file @
b6c2eab7
import
{
usePathname
}
from
"next/navigation"
;
'use client'
import
Link
from
"next/link"
;
import
{
buttonVariants
}
from
'@components/ui/button'
import
{
HoverCard
,
HoverCardContent
,
HoverCardTrigger
}
from
'@components/ui/hover-card'
import
{
cn
}
from
'@lib/utils'
import
{
cva
}
from
'class-variance-authority'
import
Link
from
'next/link'
import
{
usePathname
}
from
'next/navigation'
import
{
useCallback
,
useMemo
}
from
'react'
type
MenuItemProps
=
{
type
MenuItemProps
=
{
title
:
string
;
title
:
string
link
?:
string
;
link
?:
string
items
:
{
title
:
string
;
link
:
string
}[]
;
items
:
{
title
:
string
;
link
:
string
}[]
}
;
}
const
MenuItem
=
({
title
,
link
,
items
}:
MenuItemProps
)
=>
{
const
MenuItem
=
({
title
,
link
,
items
}:
MenuItemProps
)
=>
{
const
pathname
=
usePathname
();
const
pathname
=
usePathname
()
const
isActive
=
!!
link
&&
(
pathname
===
link
||
(
link
!==
"/"
&&
pathname
.
startsWith
(
link
)));
const
normalizedLink
=
link
&&
link
!==
'#'
?
link
:
'/'
const
hasChildren
=
items
.
length
>
0
const
isRoot
=
normalizedLink
===
'/'
const
isActive
=
isRoot
?
pathname
===
'/'
:
pathname
===
normalizedLink
||
pathname
.
startsWith
(
normalizedLink
)
const
linkId
=
useMemo
(()
=>
`header-trigger-
${
title
}
`
,
[
title
])
const
hoverCardRef
=
useCallback
(
(
element
:
HTMLDivElement
)
=>
{
if
(
!
element
)
return
const
triggerWidth
=
document
.
getElementById
(
linkId
)?.
offsetWidth
??
220
element
.
style
.
minWidth
=
`
${
Math
.
max
(
triggerWidth
,
320
)}
px`
element
.
style
.
maxWidth
=
'420px'
},
[
linkId
]
)
const
trigger
=
(
<
Link
id=
{
linkId
}
href=
{
normalizedLink
}
aria
-
selected=
{
isActive
}
className=
{
menuItemTriggerVariant
()
}
>
<
span
className=
"relative z-10 whitespace-nowrap"
>
{
title
}
</
span
>
<
span
className=
{
`absolute bottom-[11px] left-1/2 h-[2px] -translate-x-1/2 rounded-full bg-[#2f57ff] transition-all duration-200 ${
isActive ? 'w-[44px]' : 'w-0 group-hover:w-[44px]'
}`
}
aria
-
hidden=
"true"
/>
</
Link
>
)
if
(
!
hasChildren
)
{
return
<
div
className=
"relative shrink-0"
>
{
trigger
}
</
div
>
}
return
(
return
(
<
div
className=
"group relative"
>
<
HoverCard
openDelay=
{
0
}
closeDelay=
{
90
}
>
<
Link
<
HoverCardTrigger
asChild
>
{
trigger
}
</
HoverCardTrigger
>
href=
{
link
??
"#"
}
<
HoverCardContent
className=
{
`px-3 py-5 text-[16px] font-semibold transition block
ref=
{
hoverCardRef
}
${isActive ? "text-[#E8C518]" : "text-[#124588] hover:text-[#E8C518]"}
align=
"start"
`
}
sideOffset=
{
0
}
className=
{
menuItemHoverBoxVariant
()
}
>
>
{
title
}
{
items
.
map
((
item
)
=>
{
</
Link
>
const
isItemActive
=
pathname
===
item
.
link
{
/* Dropdown */
}
<
div
className=
"absolute left-0 top-full hidden group-hover:block bg-[#124588]/98 text-white text-[14px] font-medium min-w-[220px] shadow-lg"
>
{
items
.
map
((
item
,
i
)
=>
{
const
isItemActive
=
pathname
===
item
.
link
;
return
(
return
(
<
Link
<
Link
key=
{
i
}
key=
{
i
tem
.
link
}
href=
{
item
.
link
}
href=
{
item
.
link
}
className=
{
`block px-5 py-3 cursor-pointer whitespace-nowrap transition ${isItemActive ? "bg-[#e8c518]/80" : "hover:bg-[#e8c518]/80"
className=
{
menuItemChildVariant
({
active
:
isItemActive
})
}
}`
}
>
>
{
item
.
title
}
{
item
.
title
}
</
Link
>
</
Link
>
)
;
)
})
}
})
}
</
div
>
</
HoverCardContent
>
</
div
>
</
HoverCard
>
);
)
};
}
const
menuItemTriggerVariant
=
cva
(
cn
(
buttonVariants
({
variant
:
'ghost'
}),
'group relative inline-flex h-[58px] shrink-0 items-center whitespace-nowrap rounded-none bg-transparent px-[4px] py-0 text-[14px] font-semibold leading-none tracking-normal text-[#43506a] shadow-none transition'
,
'hover:bg-transparent hover:text-[#2f57ff]'
,
'aria-selected:bg-transparent aria-selected:text-[#2f57ff]'
,
'focus-visible:ring-0 focus-visible:ring-offset-0'
)
)
const
menuItemHoverBoxVariant
=
cva
(
'z-[80] flex w-auto flex-col gap-1 rounded-b-md rounded-t-none border border-slate-200 bg-white p-2 text-[13px] font-medium text-slate-600 shadow-[0_18px_36px_rgba(15,23,42,0.16)]'
)
const
menuItemChildVariant
=
cva
(
cn
(
buttonVariants
({
variant
:
'ghost'
}),
'h-auto min-h-10 justify-start rounded-md px-3 py-2.5 text-left text-sm font-medium leading-6 whitespace-normal break-words transition'
),
{
variants
:
{
active
:
{
true
:
'bg-[#eef3ff] text-[#2f57ff]'
,
false
:
'text-slate-600 hover:bg-[#eef3ff] hover:text-[#2f57ff]'
}
},
defaultVariants
:
{
active
:
false
}
}
)
export
default
MenuItem
;
export
default
MenuItem
src/components/ui/hover-card.tsx
View file @
b6c2eab7
...
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
...
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
React
.
ElementRef
<
typeof
HoverCardPrimitive
.
Content
>
,
React
.
ElementRef
<
typeof
HoverCardPrimitive
.
Content
>
,
React
.
ComponentPropsWithoutRef
<
typeof
HoverCardPrimitive
.
Content
>
React
.
ComponentPropsWithoutRef
<
typeof
HoverCardPrimitive
.
Content
>
>
(({
className
,
align
=
"center"
,
sideOffset
=
4
,
...
props
},
ref
)
=>
(
>
(({
className
,
align
=
"center"
,
sideOffset
=
4
,
...
props
},
ref
)
=>
(
<
HoverCardPrimitive
.
Content
<
HoverCardPrimitive
.
Portal
>
ref=
{
ref
}
<
HoverCardPrimitive
.
Content
align=
{
align
}
ref=
{
ref
}
sideOffset=
{
sideOffset
}
align=
{
align
}
className=
{
cn
(
sideOffset=
{
sideOffset
}
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]"
,
className=
{
cn
(
className
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]"
,
)
}
className
{
...
props
}
)
}
/>
{
...
props
}
/>
</
HoverCardPrimitive
.
Portal
>
))
))
HoverCardContent
.
displayName
=
HoverCardPrimitive
.
Content
.
displayName
HoverCardContent
.
displayName
=
HoverCardPrimitive
.
Content
.
displayName
...
...
src/styles/_components.css
View file @
b6c2eab7
...
@@ -9,10 +9,28 @@
...
@@ -9,10 +9,28 @@
@apply
sticky
top-0;
@apply
sticky
top-0;
}
}
.header-fixed
{
.header-fixed
{
@apply
fixed
top-0
right-0
left-0;
@apply
fixed
top-0
right-0
left-0;
}
}
.header-menu-scroll
{
-ms-overflow-style
:
none
;
scrollbar-width
:
none
;
}
.header-menu-scroll
::-webkit-scrollbar
{
display
:
none
;
}
.menu-item-underline
{
@apply
absolute
bottom-0
left-1/2
h-[2px]
w-0
-translate-x-1/2
rounded-full
bg-[#2f57ff]
transition-all
duration-200;
}
[
aria-selected
=
"true"
]
>
.menu-item-underline
,
a
:hover
>
.menu-item-underline
{
@apply
w-[44px];
}
/* Scrollbar */
/* Scrollbar */
.scrollbar
{
.scrollbar
{
scrollbar-color
:
rgba
(
6
,
62
,
142
,
0.38
)
rgba
(
219
,
232
,
255
,
0.38
);
scrollbar-color
:
rgba
(
6
,
62
,
142
,
0.38
)
rgba
(
219
,
232
,
255
,
0.38
);
...
...
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