keystone-design-bootstrap 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/package.json +59 -0
- package/src/contexts/ThemeContext.tsx +34 -0
- package/src/contexts/index.ts +1 -0
- package/src/design_system/elements/IconComponent.tsx +98 -0
- package/src/design_system/elements/avatar/avatar-label-group.tsx +30 -0
- package/src/design_system/elements/avatar/avatar-profile-photo.tsx +125 -0
- package/src/design_system/elements/avatar/avatar.tsx +131 -0
- package/src/design_system/elements/avatar/base-components/avatar-add-button.tsx +34 -0
- package/src/design_system/elements/avatar/base-components/avatar-company-icon.tsx +26 -0
- package/src/design_system/elements/avatar/base-components/avatar-online-indicator.tsx +31 -0
- package/src/design_system/elements/avatar/base-components/index.tsx +4 -0
- package/src/design_system/elements/avatar/base-components/verified-tick.tsx +34 -0
- package/src/design_system/elements/avatar/utils.ts +12 -0
- package/src/design_system/elements/badges/avatar.tsx +132 -0
- package/src/design_system/elements/badges/badge-groups.tsx +176 -0
- package/src/design_system/elements/badges/badge-types.ts +266 -0
- package/src/design_system/elements/badges/badges.tsx +430 -0
- package/src/design_system/elements/breadcrumb/Breadcrumb.tsx +33 -0
- package/src/design_system/elements/button-group/button-group.tsx +106 -0
- package/src/design_system/elements/buttons/app-store-buttons-outline.tsx +378 -0
- package/src/design_system/elements/buttons/app-store-buttons.tsx +567 -0
- package/src/design_system/elements/buttons/button-utility.tsx +116 -0
- package/src/design_system/elements/buttons/button.aman.tsx +174 -0
- package/src/design_system/elements/buttons/button.tsx +271 -0
- package/src/design_system/elements/buttons/close-button.tsx +42 -0
- package/src/design_system/elements/buttons/round-button.tsx +29 -0
- package/src/design_system/elements/buttons/social-button.tsx +148 -0
- package/src/design_system/elements/buttons/social-logos.tsx +115 -0
- package/src/design_system/elements/carousel/carousel-base.tsx +308 -0
- package/src/design_system/elements/carousel/carousel.tsx +308 -0
- package/src/design_system/elements/checkbox/checkbox.tsx +120 -0
- package/src/design_system/elements/date-picker/calendar.tsx +101 -0
- package/src/design_system/elements/date-picker/cell.tsx +106 -0
- package/src/design_system/elements/date-picker/date-input.tsx +32 -0
- package/src/design_system/elements/date-picker/date-picker.tsx +86 -0
- package/src/design_system/elements/date-picker/date-range-picker.tsx +163 -0
- package/src/design_system/elements/date-picker/range-calendar.tsx +161 -0
- package/src/design_system/elements/date-picker/range-preset.tsx +28 -0
- package/src/design_system/elements/featured-icon/featured-icon.tsx +154 -0
- package/src/design_system/elements/form/form.tsx +10 -0
- package/src/design_system/elements/form/hook-form.tsx +75 -0
- package/src/design_system/elements/hint-text/hint-text.tsx +33 -0
- package/src/design_system/elements/index.tsx +158 -0
- package/src/design_system/elements/input/hint-text.tsx +33 -0
- package/src/design_system/elements/input/input-group.tsx +133 -0
- package/src/design_system/elements/input/input.aman.tsx +172 -0
- package/src/design_system/elements/input/input.tsx +271 -0
- package/src/design_system/elements/input/label.tsx +50 -0
- package/src/design_system/elements/label/label.tsx +50 -0
- package/src/design_system/elements/loading-indicator/loading-indicator.tsx +123 -0
- package/src/design_system/elements/map/GoogleMap.tsx +286 -0
- package/src/design_system/elements/markdown-renderer/MarkdownRenderer.tsx +155 -0
- package/src/design_system/elements/modals/modal.tsx +41 -0
- package/src/design_system/elements/pagination/pagination-base.tsx +378 -0
- package/src/design_system/elements/pagination/pagination-dot.tsx +54 -0
- package/src/design_system/elements/pagination/pagination-line.tsx +50 -0
- package/src/design_system/elements/pagination/pagination.tsx +330 -0
- package/src/design_system/elements/photo-fallback/photo-fallback.tsx +143 -0
- package/src/design_system/elements/progress-indicators/progress-circles.tsx +176 -0
- package/src/design_system/elements/progress-indicators/progress-indicators.tsx +123 -0
- package/src/design_system/elements/progress-indicators/simple-circle.tsx +29 -0
- package/src/design_system/elements/radio-buttons/radio-buttons.tsx +129 -0
- package/src/design_system/elements/rating/rating-badge.tsx +144 -0
- package/src/design_system/elements/rating/rating-stars.tsx +77 -0
- package/src/design_system/elements/select/combobox.tsx +152 -0
- package/src/design_system/elements/select/multi-select.tsx +363 -0
- package/src/design_system/elements/select/popover.tsx +34 -0
- package/src/design_system/elements/select/select-item.tsx +97 -0
- package/src/design_system/elements/select/select-native.tsx +69 -0
- package/src/design_system/elements/select/select.aman.tsx +75 -0
- package/src/design_system/elements/select/select.tsx +146 -0
- package/src/design_system/elements/shared-assets/credit-card/credit-card.tsx +237 -0
- package/src/design_system/elements/shared-assets/credit-card/icons.tsx +75 -0
- package/src/design_system/elements/shared-assets/iphone-mockup.tsx +172 -0
- package/src/design_system/elements/shared-assets/section-divider.tsx +12 -0
- package/src/design_system/elements/slideout-menus/slideout-menu.tsx +122 -0
- package/src/design_system/elements/tabs/tabs.tsx +225 -0
- package/src/design_system/elements/tags/base-components/tag-checkbox.tsx +45 -0
- package/src/design_system/elements/tags/base-components/tag-close-x.tsx +34 -0
- package/src/design_system/elements/tags/tags.tsx +176 -0
- package/src/design_system/elements/textarea/textarea.aman.tsx +52 -0
- package/src/design_system/elements/textarea/textarea.tsx +111 -0
- package/src/design_system/elements/toggle/toggle.tsx +140 -0
- package/src/design_system/elements/tooltip/tooltip.tsx +109 -0
- package/src/design_system/hooks/use-breakpoint.ts +37 -0
- package/src/design_system/hooks/use-resize-observer.ts +68 -0
- package/src/design_system/logo/keystone-logo-minimal.tsx +93 -0
- package/src/design_system/logo/keystone-logo.tsx +22 -0
- package/src/design_system/sections/about-home.aman.tsx +85 -0
- package/src/design_system/sections/about-home.tsx +115 -0
- package/src/design_system/sections/blog-cards.tsx +848 -0
- package/src/design_system/sections/blog-gallery.aman.tsx +77 -0
- package/src/design_system/sections/blog-gallery.tsx +204 -0
- package/src/design_system/sections/blog-home.aman.tsx +84 -0
- package/src/design_system/sections/blog-home.tsx +153 -0
- package/src/design_system/sections/blog-post.aman.tsx +74 -0
- package/src/design_system/sections/blog-post.tsx +301 -0
- package/src/design_system/sections/blog-section.aman.tsx +101 -0
- package/src/design_system/sections/blog-section.tsx +179 -0
- package/src/design_system/sections/contact-home.tsx +25 -0
- package/src/design_system/sections/contact-section.aman.tsx +173 -0
- package/src/design_system/sections/contact-section.tsx +143 -0
- package/src/design_system/sections/faq-grid.aman.tsx +79 -0
- package/src/design_system/sections/faq-grid.tsx +102 -0
- package/src/design_system/sections/faq-home.aman.tsx +92 -0
- package/src/design_system/sections/faq-home.tsx +134 -0
- package/src/design_system/sections/feature-tab.tsx +43 -0
- package/src/design_system/sections/feature-text.tsx +284 -0
- package/src/design_system/sections/footer-home.aman.tsx +62 -0
- package/src/design_system/sections/footer-home.tsx +259 -0
- package/src/design_system/sections/generic-header-component.tsx +103 -0
- package/src/design_system/sections/header-navigation.aman.tsx +360 -0
- package/src/design_system/sections/header-navigation.tsx +334 -0
- package/src/design_system/sections/hero-faq.aman.tsx +38 -0
- package/src/design_system/sections/hero-faq.tsx +55 -0
- package/src/design_system/sections/hero-generic-text.aman.tsx +49 -0
- package/src/design_system/sections/hero-generic-text.tsx +51 -0
- package/src/design_system/sections/hero-home.aman.tsx +84 -0
- package/src/design_system/sections/hero-home.tsx +246 -0
- package/src/design_system/sections/hero-location-detail.aman.tsx +33 -0
- package/src/design_system/sections/hero-location-detail.tsx +72 -0
- package/src/design_system/sections/hero-service-detail.aman.tsx +53 -0
- package/src/design_system/sections/hero-service-detail.tsx +51 -0
- package/src/design_system/sections/hero-social-media.aman.tsx +42 -0
- package/src/design_system/sections/hero-social-media.tsx +35 -0
- package/src/design_system/sections/hero-testimonials.aman.tsx +38 -0
- package/src/design_system/sections/hero-testimonials.tsx +55 -0
- package/src/design_system/sections/home-hero-component.tsx +228 -0
- package/src/design_system/sections/index.tsx +131 -0
- package/src/design_system/sections/job-gallery.aman.tsx +91 -0
- package/src/design_system/sections/job-gallery.tsx +183 -0
- package/src/design_system/sections/location-details-section.aman.tsx +179 -0
- package/src/design_system/sections/location-details-section.tsx +196 -0
- package/src/design_system/sections/location-grid.aman.tsx +76 -0
- package/src/design_system/sections/location-grid.tsx +123 -0
- package/src/design_system/sections/services-grid.aman.tsx +85 -0
- package/src/design_system/sections/services-grid.tsx +104 -0
- package/src/design_system/sections/services-home.aman.tsx +78 -0
- package/src/design_system/sections/services-home.tsx +131 -0
- package/src/design_system/sections/social-media-grid.aman.tsx +132 -0
- package/src/design_system/sections/social-media-grid.tsx +189 -0
- package/src/design_system/sections/statistics-section.aman.tsx +79 -0
- package/src/design_system/sections/statistics-section.tsx +97 -0
- package/src/design_system/sections/team-grid.aman.tsx +85 -0
- package/src/design_system/sections/team-grid.tsx +88 -0
- package/src/design_system/sections/testimonials-home.aman.tsx +113 -0
- package/src/design_system/sections/testimonials-home.tsx +90 -0
- package/src/design_system/sections/values-section.aman.tsx +73 -0
- package/src/design_system/sections/values-section.tsx +128 -0
- package/src/design_system/utils/icon-mapping.tsx +28 -0
- package/src/index.ts +7 -0
- package/src/lib/component-registry.ts +53 -0
- package/src/lib/hooks/index.ts +8 -0
- package/src/lib/hooks/use-breakpoint.ts +37 -0
- package/src/lib/hooks/use-clipboard.ts +79 -0
- package/src/lib/hooks/use-resize-observer.ts +68 -0
- package/src/lib/server-api.ts +115 -0
- package/src/styles/style-overrides.aman.css +101 -0
- package/src/styles/theme.css +224 -0
- package/src/styles/typography.css +430 -0
- package/src/themes/index.ts +23 -0
- package/src/types/api/blog-post.ts +53 -0
- package/src/types/api/company-information.ts +44 -0
- package/src/types/api/contact.ts +63 -0
- package/src/types/api/faq.ts +37 -0
- package/src/types/api/job-posting.ts +34 -0
- package/src/types/api/location.ts +36 -0
- package/src/types/api/photos.ts +28 -0
- package/src/types/api/service.ts +37 -0
- package/src/types/api/social-post.ts +28 -0
- package/src/types/api/team-member.ts +29 -0
- package/src/types/api/testimonial.ts +29 -0
- package/src/types/api/website-photos.ts +22 -0
- package/src/types/config.ts +21 -0
- package/src/types/index.ts +21 -0
- package/src/utils/countries.tsx +1351 -0
- package/src/utils/cx.ts +25 -0
- package/src/utils/gradient-placeholder.ts +59 -0
- package/src/utils/is-react-component.ts +33 -0
- package/src/utils/markdown-toc.ts +54 -0
- package/src/utils/photo-helpers.ts +94 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { Button } from '../elements';
|
|
6
|
+
import { cx } from '../../utils/cx';
|
|
7
|
+
import { getLogoUrl } from '../../utils/photo-helpers';
|
|
8
|
+
import type { HeaderComponentProps } from './header-navigation';
|
|
9
|
+
|
|
10
|
+
// Maximum items to show before "View All" link
|
|
11
|
+
const MAX_DROPDOWN_ITEMS = 3;
|
|
12
|
+
|
|
13
|
+
export function HeaderNavigation({
|
|
14
|
+
variant,
|
|
15
|
+
props,
|
|
16
|
+
navigation: navigationOverride,
|
|
17
|
+
logoImage: logoImageOverride,
|
|
18
|
+
logoText: logoTextOverride,
|
|
19
|
+
config,
|
|
20
|
+
companyInformation,
|
|
21
|
+
websitePhotos,
|
|
22
|
+
}: HeaderComponentProps) {
|
|
23
|
+
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
|
24
|
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
25
|
+
const [dropdownTop, setDropdownTop] = useState(0);
|
|
26
|
+
const [isScrolled, setIsScrolled] = useState(false);
|
|
27
|
+
|
|
28
|
+
// Timeout ref for delayed dropdown closing
|
|
29
|
+
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
30
|
+
|
|
31
|
+
// Track scroll position for header animation
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
const handleScroll = () => {
|
|
34
|
+
setIsScrolled(window.scrollY > 10);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
window.addEventListener('scroll', handleScroll);
|
|
38
|
+
return () => window.removeEventListener('scroll', handleScroll);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
// Cleanup timeout on unmount
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
return () => {
|
|
44
|
+
if (closeTimeoutRef.current) {
|
|
45
|
+
clearTimeout(closeTimeoutRef.current);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const apiCompanyInfo = companyInformation as any;
|
|
51
|
+
|
|
52
|
+
const logoUrl = logoImageOverride || getLogoUrl(websitePhotos) || props?.logo?.image;
|
|
53
|
+
const companyName = logoTextOverride || apiCompanyInfo?.company_name || props?.logo?.text || '';
|
|
54
|
+
|
|
55
|
+
// Use navigation from config or override
|
|
56
|
+
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
57
|
+
|
|
58
|
+
// Cancel any pending close timeout
|
|
59
|
+
const cancelCloseTimeout = useCallback(() => {
|
|
60
|
+
if (closeTimeoutRef.current) {
|
|
61
|
+
clearTimeout(closeTimeoutRef.current);
|
|
62
|
+
closeTimeoutRef.current = null;
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Open dropdown immediately
|
|
67
|
+
const handleMouseEnter = useCallback((item: any, e: React.MouseEvent<HTMLDivElement>) => {
|
|
68
|
+
cancelCloseTimeout();
|
|
69
|
+
if (item.children?.length > 0) {
|
|
70
|
+
const target = e.currentTarget.closest('nav');
|
|
71
|
+
if (target) {
|
|
72
|
+
const rect = target.getBoundingClientRect();
|
|
73
|
+
setDropdownTop(rect.bottom);
|
|
74
|
+
}
|
|
75
|
+
setActiveDropdown(item.label);
|
|
76
|
+
}
|
|
77
|
+
}, [cancelCloseTimeout]);
|
|
78
|
+
|
|
79
|
+
// Close dropdown with delay (allows moving to dropdown)
|
|
80
|
+
const handleMouseLeave = useCallback(() => {
|
|
81
|
+
cancelCloseTimeout();
|
|
82
|
+
closeTimeoutRef.current = setTimeout(() => {
|
|
83
|
+
setActiveDropdown(null);
|
|
84
|
+
}, 150); // 150ms delay before closing
|
|
85
|
+
}, [cancelCloseTimeout]);
|
|
86
|
+
|
|
87
|
+
// Keep dropdown open when hovering over it
|
|
88
|
+
const handleDropdownMouseEnter = useCallback(() => {
|
|
89
|
+
cancelCloseTimeout();
|
|
90
|
+
}, [cancelCloseTimeout]);
|
|
91
|
+
|
|
92
|
+
// Close dropdown when leaving the dropdown area
|
|
93
|
+
const handleDropdownMouseLeave = useCallback(() => {
|
|
94
|
+
handleMouseLeave();
|
|
95
|
+
}, [handleMouseLeave]);
|
|
96
|
+
|
|
97
|
+
// Get display items for dropdown (limit + "View All" if needed)
|
|
98
|
+
const getDropdownItems = (item: any) => {
|
|
99
|
+
const children = item.children || [];
|
|
100
|
+
const isDynamicMenu = item.label === 'Services' || item.label === 'Locations';
|
|
101
|
+
|
|
102
|
+
if (isDynamicMenu && children.length > MAX_DROPDOWN_ITEMS + 1) {
|
|
103
|
+
// Show first MAX_DROPDOWN_ITEMS items + "View All" link
|
|
104
|
+
const displayItems = children.slice(0, MAX_DROPDOWN_ITEMS);
|
|
105
|
+
const viewAllHref = item.label === 'Services' ? '/services' : '/locations';
|
|
106
|
+
return {
|
|
107
|
+
items: displayItems,
|
|
108
|
+
showViewAll: true,
|
|
109
|
+
viewAllHref,
|
|
110
|
+
viewAllLabel: `View All ${item.label}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
items: children,
|
|
116
|
+
showViewAll: false,
|
|
117
|
+
viewAllHref: '',
|
|
118
|
+
viewAllLabel: '',
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{/* Desktop Header */}
|
|
125
|
+
<header className="hidden md:block sticky top-0 z-50 bg-primary border-b border-secondary transition-all duration-300">
|
|
126
|
+
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
127
|
+
{/* Top Row: Logo (left), Company Name (center), Contact Button (right) */}
|
|
128
|
+
<div className={cx(
|
|
129
|
+
"relative flex items-center justify-between transition-all duration-300",
|
|
130
|
+
isScrolled ? "py-2" : "py-8"
|
|
131
|
+
)}>
|
|
132
|
+
{/* Left: Logo Image */}
|
|
133
|
+
<Link href="/" className="flex items-center">
|
|
134
|
+
{logoUrl && (
|
|
135
|
+
<img
|
|
136
|
+
src={logoUrl}
|
|
137
|
+
alt={companyName}
|
|
138
|
+
className="h-8 md:h-10 w-auto object-contain"
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
</Link>
|
|
142
|
+
|
|
143
|
+
{/* Center: Company Name Text */}
|
|
144
|
+
<Link href="/" className="absolute left-1/2 transform -translate-x-1/2 font-display text-2xl md:text-3xl font-normal uppercase tracking-widest text-fg-primary" suppressHydrationWarning>
|
|
145
|
+
{companyName}
|
|
146
|
+
</Link>
|
|
147
|
+
|
|
148
|
+
{/* Right: Contact Button */}
|
|
149
|
+
<div className="flex items-center gap-6">
|
|
150
|
+
<Button
|
|
151
|
+
href="/contact"
|
|
152
|
+
size="sm"
|
|
153
|
+
className="bg-fg-primary text-white font-body text-sm uppercase tracking-wide px-6 py-2 rounded-sm hover:opacity-90"
|
|
154
|
+
>
|
|
155
|
+
Contact
|
|
156
|
+
</Button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Navigation Row with Dropdowns - Centered */}
|
|
161
|
+
<nav className="border-b border-secondary">
|
|
162
|
+
<div className="flex items-center justify-center gap-8 py-4">
|
|
163
|
+
{navigation.map((item: any, i: number) => (
|
|
164
|
+
<div
|
|
165
|
+
key={i}
|
|
166
|
+
className="relative"
|
|
167
|
+
onMouseEnter={(e) => handleMouseEnter(item, e)}
|
|
168
|
+
onMouseLeave={handleMouseLeave}
|
|
169
|
+
>
|
|
170
|
+
<Link
|
|
171
|
+
href={item.href}
|
|
172
|
+
className={cx(
|
|
173
|
+
"font-body text-sm uppercase tracking-wide text-fg-primary pb-4",
|
|
174
|
+
activeDropdown === item.label && "border-b-2 border-fg-primary"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{item.label}
|
|
178
|
+
</Link>
|
|
179
|
+
|
|
180
|
+
{/* Dropdown Second Row - Horizontal Layout - Centered */}
|
|
181
|
+
{item.children && item.children.length > 0 && activeDropdown === item.label && (
|
|
182
|
+
<div
|
|
183
|
+
className="fixed left-0 right-0 w-full pt-6 pb-6 border-b border-secondary bg-primary z-50"
|
|
184
|
+
style={{ top: `${dropdownTop}px` }}
|
|
185
|
+
onMouseEnter={handleDropdownMouseEnter}
|
|
186
|
+
onMouseLeave={handleDropdownMouseLeave}
|
|
187
|
+
>
|
|
188
|
+
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
189
|
+
<div className="flex items-center justify-center gap-8">
|
|
190
|
+
{(() => {
|
|
191
|
+
const { items, showViewAll, viewAllHref, viewAllLabel } = getDropdownItems(item);
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
{items.map((link: any, j: number) => (
|
|
195
|
+
<Link
|
|
196
|
+
key={j}
|
|
197
|
+
href={link.href}
|
|
198
|
+
className="font-body text-sm text-fg-primary hover:underline whitespace-nowrap"
|
|
199
|
+
>
|
|
200
|
+
{link.label}
|
|
201
|
+
</Link>
|
|
202
|
+
))}
|
|
203
|
+
{showViewAll && (
|
|
204
|
+
<Link
|
|
205
|
+
href={viewAllHref}
|
|
206
|
+
className="font-body text-sm text-fg-primary hover:underline whitespace-nowrap font-semibold"
|
|
207
|
+
>
|
|
208
|
+
{viewAllLabel} →
|
|
209
|
+
</Link>
|
|
210
|
+
)}
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
})()}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
</nav>
|
|
222
|
+
</div>
|
|
223
|
+
</header>
|
|
224
|
+
|
|
225
|
+
{/* Mobile Header */}
|
|
226
|
+
<header className="md:hidden sticky top-0 z-50 bg-primary border-b border-secondary">
|
|
227
|
+
<div className="flex items-center justify-between px-4 py-4">
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => setIsMobileMenuOpen(true)}
|
|
230
|
+
className="text-fg-primary"
|
|
231
|
+
aria-label="Open menu"
|
|
232
|
+
>
|
|
233
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
234
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
235
|
+
</svg>
|
|
236
|
+
</button>
|
|
237
|
+
|
|
238
|
+
<Link href="/" className="flex items-center">
|
|
239
|
+
{logoUrl ? (
|
|
240
|
+
<img
|
|
241
|
+
src={logoUrl}
|
|
242
|
+
alt={companyName}
|
|
243
|
+
className="h-8 w-auto object-contain"
|
|
244
|
+
/>
|
|
245
|
+
) : (
|
|
246
|
+
<span className="font-display text-xl font-normal uppercase tracking-widest text-fg-primary">
|
|
247
|
+
{companyName}
|
|
248
|
+
</span>
|
|
249
|
+
)}
|
|
250
|
+
</Link>
|
|
251
|
+
|
|
252
|
+
<div className="w-6" />
|
|
253
|
+
</div>
|
|
254
|
+
</header>
|
|
255
|
+
|
|
256
|
+
{/* Mobile Full-Screen Menu Overlay */}
|
|
257
|
+
{isMobileMenuOpen && (
|
|
258
|
+
<div className="fixed inset-0 bg-white z-50 md:hidden">
|
|
259
|
+
<div className="flex flex-col h-full">
|
|
260
|
+
{/* Mobile Menu Header */}
|
|
261
|
+
<div className="flex items-center justify-between px-4 py-4 border-b border-secondary">
|
|
262
|
+
<button
|
|
263
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
264
|
+
className="text-fg-primary"
|
|
265
|
+
aria-label="Close menu"
|
|
266
|
+
>
|
|
267
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
268
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
269
|
+
</svg>
|
|
270
|
+
</button>
|
|
271
|
+
|
|
272
|
+
<div className="flex items-center">
|
|
273
|
+
{logoUrl ? (
|
|
274
|
+
<img
|
|
275
|
+
src={logoUrl}
|
|
276
|
+
alt={companyName}
|
|
277
|
+
className="h-8 w-auto object-contain"
|
|
278
|
+
/>
|
|
279
|
+
) : (
|
|
280
|
+
<span className="font-display text-xl font-normal uppercase tracking-widest text-fg-primary">
|
|
281
|
+
{companyName}
|
|
282
|
+
</span>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div className="w-6" />
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Mobile Menu Links */}
|
|
290
|
+
<nav className="flex-1 overflow-y-auto px-4 py-8">
|
|
291
|
+
<ul className="space-y-4">
|
|
292
|
+
{navigation.map((item: any, i: number) => {
|
|
293
|
+
const { items, showViewAll, viewAllHref, viewAllLabel } = getDropdownItems(item);
|
|
294
|
+
return (
|
|
295
|
+
<li key={i}>
|
|
296
|
+
<Link
|
|
297
|
+
href={item.href}
|
|
298
|
+
className="block font-body text-base text-fg-primary py-2"
|
|
299
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
300
|
+
>
|
|
301
|
+
{item.label}
|
|
302
|
+
</Link>
|
|
303
|
+
{items.length > 0 && (
|
|
304
|
+
<ul className="ml-4 mt-2 space-y-2">
|
|
305
|
+
{items.map((link: any, j: number) => (
|
|
306
|
+
<li key={j}>
|
|
307
|
+
<Link
|
|
308
|
+
href={link.href}
|
|
309
|
+
className="block font-body text-sm text-tertiary py-1"
|
|
310
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
311
|
+
>
|
|
312
|
+
{link.label}
|
|
313
|
+
</Link>
|
|
314
|
+
</li>
|
|
315
|
+
))}
|
|
316
|
+
{showViewAll && (
|
|
317
|
+
<li>
|
|
318
|
+
<Link
|
|
319
|
+
href={viewAllHref}
|
|
320
|
+
className="block font-body text-sm text-tertiary py-1 font-semibold"
|
|
321
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
322
|
+
>
|
|
323
|
+
{viewAllLabel} →
|
|
324
|
+
</Link>
|
|
325
|
+
</li>
|
|
326
|
+
)}
|
|
327
|
+
</ul>
|
|
328
|
+
)}
|
|
329
|
+
</li>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</ul>
|
|
333
|
+
</nav>
|
|
334
|
+
|
|
335
|
+
{/* Mobile Menu Footer */}
|
|
336
|
+
<div className="border-t border-secondary px-4 py-4">
|
|
337
|
+
<select className="w-full font-body text-sm text-fg-primary bg-transparent border-b border-secondary py-2">
|
|
338
|
+
<option>English</option>
|
|
339
|
+
</select>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{/* Sticky Contact Button (Mobile) */}
|
|
346
|
+
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden" style={{ backgroundColor: '#1E1E1E' }}>
|
|
347
|
+
<Button
|
|
348
|
+
href="/contact"
|
|
349
|
+
className="w-full font-body text-sm uppercase tracking-wide py-4 rounded-none"
|
|
350
|
+
style={{ backgroundColor: '#1E1E1E', color: '#FFFFFF' }}
|
|
351
|
+
>
|
|
352
|
+
Contact
|
|
353
|
+
</Button>
|
|
354
|
+
</div>
|
|
355
|
+
</>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
import { registerThemeVariant } from '../../lib/component-registry';
|
|
360
|
+
registerThemeVariant('header-navigation', 'aman', HeaderNavigation);
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useRef, useState } from 'react';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { ChevronDown } from "@untitledui/icons";
|
|
5
|
+
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
|
6
|
+
import { Button } from '../elements';
|
|
7
|
+
import { GenericHeaderComponent } from './generic-header-component';
|
|
8
|
+
import { cx } from '../../utils/cx';
|
|
9
|
+
import { getLogoUrl } from '../../utils/photo-helpers';
|
|
10
|
+
import type { CompanyInformation } from '../../types/api/company-information';
|
|
11
|
+
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
12
|
+
import type { NavItem, SiteConfig } from '../../types/config';
|
|
13
|
+
|
|
14
|
+
export interface HeaderProps {
|
|
15
|
+
logo: {
|
|
16
|
+
text: string;
|
|
17
|
+
href: string;
|
|
18
|
+
image?: string;
|
|
19
|
+
};
|
|
20
|
+
cta_button?: {
|
|
21
|
+
label: string;
|
|
22
|
+
href: string;
|
|
23
|
+
secondary_label?: string;
|
|
24
|
+
secondary_href?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeaderComponentProps {
|
|
29
|
+
variant?: 'minimal' | 'centered' | 'standard';
|
|
30
|
+
props?: HeaderProps;
|
|
31
|
+
navigation?: NavItem[];
|
|
32
|
+
logoImage?: string;
|
|
33
|
+
logoText?: string;
|
|
34
|
+
config?: SiteConfig;
|
|
35
|
+
companyInformation?: CompanyInformation | null;
|
|
36
|
+
websitePhotos?: WebsitePhotos | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MobileNavItem = ({ label, href, children, onClose }: {
|
|
40
|
+
label: string;
|
|
41
|
+
href?: string;
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
onClose?: () => void;
|
|
44
|
+
}) => {
|
|
45
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
if (href) {
|
|
48
|
+
return (
|
|
49
|
+
<li>
|
|
50
|
+
<Link
|
|
51
|
+
href={href}
|
|
52
|
+
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-primary hover:bg-primary_hover"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
>
|
|
55
|
+
{label}
|
|
56
|
+
</Link>
|
|
57
|
+
</li>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<li className="flex flex-col gap-0.5">
|
|
63
|
+
<button
|
|
64
|
+
aria-expanded={isOpen}
|
|
65
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
66
|
+
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-primary hover:bg-primary_hover"
|
|
67
|
+
>
|
|
68
|
+
{label}
|
|
69
|
+
<ChevronDown
|
|
70
|
+
className={cx("size-4 stroke-[2.625px] text-fg-quaternary transition duration-100 ease-linear", isOpen ? "-rotate-180" : "rotate-0")}
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
{isOpen && <div>{children}</div>}
|
|
74
|
+
</li>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function HeaderNavigation({
|
|
79
|
+
variant = 'standard',
|
|
80
|
+
props,
|
|
81
|
+
navigation: navigationOverride,
|
|
82
|
+
logoImage: logoImageOverride,
|
|
83
|
+
logoText: logoTextOverride,
|
|
84
|
+
config,
|
|
85
|
+
companyInformation,
|
|
86
|
+
websitePhotos,
|
|
87
|
+
}: HeaderComponentProps) {
|
|
88
|
+
const headerRef = useRef<HTMLElement>(null);
|
|
89
|
+
|
|
90
|
+
const apiCompanyInfo = companyInformation as any;
|
|
91
|
+
|
|
92
|
+
// Use navigation from config or override
|
|
93
|
+
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
94
|
+
|
|
95
|
+
// Build logo
|
|
96
|
+
const logoImage = logoImageOverride || getLogoUrl(websitePhotos) || props?.logo?.image;
|
|
97
|
+
const logoText = logoTextOverride || apiCompanyInfo?.company_name || props?.logo?.text || '';
|
|
98
|
+
const cta_button = props?.cta_button;
|
|
99
|
+
|
|
100
|
+
const logo = {
|
|
101
|
+
text: logoText || '',
|
|
102
|
+
href: props?.logo?.href || '/',
|
|
103
|
+
image: logoImage,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const dynamicNavigation = navigation;
|
|
107
|
+
|
|
108
|
+
const getVariantClasses = () => {
|
|
109
|
+
switch (variant) {
|
|
110
|
+
case 'minimal':
|
|
111
|
+
return 'py-4';
|
|
112
|
+
case 'centered':
|
|
113
|
+
return 'py-6';
|
|
114
|
+
case 'standard':
|
|
115
|
+
default:
|
|
116
|
+
return 'py-4';
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const HoverDropdown = ({ label, href, children }: { label: string; href?: string; children: React.ReactNode }) => {
|
|
121
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
122
|
+
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
123
|
+
|
|
124
|
+
const handleMouseEnter = () => {
|
|
125
|
+
if (timeoutRef.current) {
|
|
126
|
+
clearTimeout(timeoutRef.current);
|
|
127
|
+
timeoutRef.current = null;
|
|
128
|
+
}
|
|
129
|
+
setIsOpen(true);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleMouseLeave = () => {
|
|
133
|
+
const id = setTimeout(() => {
|
|
134
|
+
setIsOpen(false);
|
|
135
|
+
}, 100);
|
|
136
|
+
timeoutRef.current = id;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
return () => {
|
|
141
|
+
if (timeoutRef.current) {
|
|
142
|
+
clearTimeout(timeoutRef.current);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
className="relative"
|
|
150
|
+
onMouseEnter={handleMouseEnter}
|
|
151
|
+
onMouseLeave={handleMouseLeave}
|
|
152
|
+
>
|
|
153
|
+
<Link
|
|
154
|
+
href={href || '#'}
|
|
155
|
+
className="flex cursor-pointer items-center gap-0.5 rounded-lg px-1.5 py-1 text-sm font-medium text-secondary outline-focus-ring transition duration-100 ease-linear hover:text-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2"
|
|
156
|
+
>
|
|
157
|
+
<span className="px-0.5">{label}</span>
|
|
158
|
+
<ChevronDown className={cx("size-4 stroke-[2.625px] text-fg-quaternary transition duration-100 ease-linear", isOpen ? "-rotate-180" : "rotate-0")} />
|
|
159
|
+
</Link>
|
|
160
|
+
|
|
161
|
+
{isOpen && (
|
|
162
|
+
<div
|
|
163
|
+
className="absolute top-full left-0 z-50 mt-1 origin-top animate-in fade-in slide-in-from-top-1 duration-75 ease-out"
|
|
164
|
+
onMouseEnter={handleMouseEnter}
|
|
165
|
+
onMouseLeave={handleMouseLeave}
|
|
166
|
+
>
|
|
167
|
+
<div className="w-max max-w-sm">
|
|
168
|
+
{children}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const headerNavItems = dynamicNavigation.map((navItem) => {
|
|
177
|
+
if (navItem.children && navItem.children.length > 0) {
|
|
178
|
+
return {
|
|
179
|
+
label: navItem.label,
|
|
180
|
+
href: navItem.href,
|
|
181
|
+
menu: (
|
|
182
|
+
<GenericHeaderComponent
|
|
183
|
+
items={navItem.children.map(child => ({
|
|
184
|
+
label: child.label,
|
|
185
|
+
href: child.href,
|
|
186
|
+
subtitle: child.subtitle,
|
|
187
|
+
}))}
|
|
188
|
+
/>
|
|
189
|
+
),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
label: navItem.label,
|
|
194
|
+
href: navItem.href,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<header
|
|
200
|
+
ref={headerRef}
|
|
201
|
+
className={cx(
|
|
202
|
+
"sticky top-0 z-50 flex h-18 w-full items-center justify-center backdrop-blur-md bg-primary/80 md:h-20",
|
|
203
|
+
getVariantClasses()
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<div className="flex size-full max-w-container flex-1 items-center pr-3 pl-4 md:px-8">
|
|
207
|
+
<div className="flex w-full justify-between gap-4">
|
|
208
|
+
<div className="flex flex-1 items-center gap-5">
|
|
209
|
+
<Link href={logo?.href || '/'} className="flex items-center space-x-2">
|
|
210
|
+
{logoImage ? (
|
|
211
|
+
<img
|
|
212
|
+
src={logoImage}
|
|
213
|
+
alt={logoText}
|
|
214
|
+
className="h-8 w-8 object-contain"
|
|
215
|
+
/>
|
|
216
|
+
) : (
|
|
217
|
+
<div className="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
|
218
|
+
<span className="text-white font-bold text-lg">
|
|
219
|
+
{logoText?.charAt(0)?.toUpperCase() || ''}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
<span className="text-xl font-bold text-primary hidden md:block">{logoText}</span>
|
|
224
|
+
</Link>
|
|
225
|
+
|
|
226
|
+
<nav className="max-md:hidden">
|
|
227
|
+
<ul className="flex items-center gap-0.5">
|
|
228
|
+
{headerNavItems.map((navItem) => (
|
|
229
|
+
<li key={navItem.label}>
|
|
230
|
+
{navItem.menu ? (
|
|
231
|
+
<HoverDropdown label={navItem.label} href={navItem.href}>
|
|
232
|
+
{navItem.menu}
|
|
233
|
+
</HoverDropdown>
|
|
234
|
+
) : (
|
|
235
|
+
<Link
|
|
236
|
+
href={navItem.href || '#'}
|
|
237
|
+
className="flex cursor-pointer items-center gap-0.5 rounded-lg px-1.5 py-1 text-sm font-medium text-secondary outline-focus-ring transition duration-100 ease-linear hover:text-secondary_hover focus:outline-offset-2 focus-visible:outline-2"
|
|
238
|
+
>
|
|
239
|
+
<span className="px-0.5">{navItem.label}</span>
|
|
240
|
+
</Link>
|
|
241
|
+
)}
|
|
242
|
+
</li>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
</nav>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div className="hidden items-center gap-3 md:flex">
|
|
249
|
+
<Button
|
|
250
|
+
href={cta_button?.href || "/contact"}
|
|
251
|
+
color="primary"
|
|
252
|
+
size="lg"
|
|
253
|
+
>
|
|
254
|
+
{cta_button?.label || "Get Started"}
|
|
255
|
+
</Button>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<AriaDialogTrigger>
|
|
259
|
+
<AriaButton
|
|
260
|
+
aria-label="Toggle navigation menu"
|
|
261
|
+
className={({ isFocusVisible, isHovered }) =>
|
|
262
|
+
cx(
|
|
263
|
+
"group ml-auto cursor-pointer rounded-lg p-2 md:hidden",
|
|
264
|
+
isHovered && "bg-primary_hover",
|
|
265
|
+
isFocusVisible && "outline-2 outline-offset-2 outline-focus-ring",
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
>
|
|
269
|
+
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
270
|
+
<path
|
|
271
|
+
className="hidden text-secondary group-aria-expanded:block"
|
|
272
|
+
d="M18 6L6 18M6 6L18 18"
|
|
273
|
+
stroke="currentColor"
|
|
274
|
+
strokeWidth="2"
|
|
275
|
+
strokeLinecap="round"
|
|
276
|
+
strokeLinejoin="round"
|
|
277
|
+
/>
|
|
278
|
+
<path
|
|
279
|
+
className="text-secondary group-aria-expanded:hidden"
|
|
280
|
+
d="M3 12H21M3 6H21M3 18H21"
|
|
281
|
+
stroke="currentColor"
|
|
282
|
+
strokeWidth="2"
|
|
283
|
+
strokeLinecap="round"
|
|
284
|
+
strokeLinejoin="round"
|
|
285
|
+
/>
|
|
286
|
+
</svg>
|
|
287
|
+
</AriaButton>
|
|
288
|
+
<AriaPopover
|
|
289
|
+
triggerRef={headerRef}
|
|
290
|
+
className="h-calc(100%-72px) scrollbar-hide w-full overflow-y-auto shadow-lg md:hidden"
|
|
291
|
+
offset={0}
|
|
292
|
+
crossOffset={20}
|
|
293
|
+
containerPadding={0}
|
|
294
|
+
placement="bottom left"
|
|
295
|
+
>
|
|
296
|
+
<AriaDialog className="outline-hidden">
|
|
297
|
+
<nav className="w-full bg-primary shadow-lg">
|
|
298
|
+
<ul className="flex flex-col gap-0.5 py-5">
|
|
299
|
+
{headerNavItems.map((navItem) =>
|
|
300
|
+
navItem.menu ? (
|
|
301
|
+
<MobileNavItem
|
|
302
|
+
key={navItem.label}
|
|
303
|
+
label={navItem.label}
|
|
304
|
+
>
|
|
305
|
+
{navItem.menu}
|
|
306
|
+
</MobileNavItem>
|
|
307
|
+
) : (
|
|
308
|
+
<MobileNavItem
|
|
309
|
+
key={navItem.label}
|
|
310
|
+
label={navItem.label}
|
|
311
|
+
href={navItem.href}
|
|
312
|
+
/>
|
|
313
|
+
),
|
|
314
|
+
)}
|
|
315
|
+
</ul>
|
|
316
|
+
|
|
317
|
+
<div className="flex flex-col gap-3 border-t border-secondary px-4 py-6">
|
|
318
|
+
<Button
|
|
319
|
+
href={cta_button?.href || "/contact"}
|
|
320
|
+
color="primary"
|
|
321
|
+
size="lg"
|
|
322
|
+
>
|
|
323
|
+
{cta_button?.label || "Get Started"}
|
|
324
|
+
</Button>
|
|
325
|
+
</div>
|
|
326
|
+
</nav>
|
|
327
|
+
</AriaDialog>
|
|
328
|
+
</AriaPopover>
|
|
329
|
+
</AriaDialogTrigger>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</header>
|
|
333
|
+
);
|
|
334
|
+
}
|