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,286 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
interface GoogleMapProps {
|
|
6
|
+
address: string;
|
|
7
|
+
locationName?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
interface Window {
|
|
13
|
+
google: any;
|
|
14
|
+
[key: string]: any; // Allow dynamic callback properties
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GOOGLE_MAPS_API_KEY = 'AIzaSyAb4-zSsPFx-QGi4cAiCGaRrzsAJC6e348';
|
|
19
|
+
|
|
20
|
+
export default function GoogleMap({ address, locationName, className = '' }: GoogleMapProps) {
|
|
21
|
+
const mapRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const [mapError, setMapError] = useState<string | null>(null);
|
|
23
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
24
|
+
const geocoderRef = useRef<any>(null);
|
|
25
|
+
const mapInstanceRef = useRef<any>(null);
|
|
26
|
+
const isInitializingRef = useRef(false);
|
|
27
|
+
const callbackNameRef = useRef<string | null>(null);
|
|
28
|
+
|
|
29
|
+
// Initialize map once Google Maps API is loaded
|
|
30
|
+
const initMap = useCallback(async () => {
|
|
31
|
+
// Prevent multiple simultaneous initializations
|
|
32
|
+
if (isInitializingRef.current) return;
|
|
33
|
+
|
|
34
|
+
// Ensure DOM element exists and Google Maps is fully loaded
|
|
35
|
+
if (!mapRef.current || !window.google || !window.google.maps) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check if Geocoder is available (required for async loading)
|
|
40
|
+
if (!window.google.maps.Geocoder) {
|
|
41
|
+
console.warn('Google Maps Geocoder not yet available, retrying...');
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
if (mapRef.current && window.google?.maps?.Geocoder) {
|
|
44
|
+
initMap();
|
|
45
|
+
}
|
|
46
|
+
}, 100);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Double-check the element is still in the DOM
|
|
51
|
+
if (!document.body.contains(mapRef.current)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isInitializingRef.current = true;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Clear any existing map instance
|
|
59
|
+
if (mapInstanceRef.current) {
|
|
60
|
+
// Clear markers and listeners
|
|
61
|
+
const markers = mapInstanceRef.current.markers || [];
|
|
62
|
+
markers.forEach((marker: any) => {
|
|
63
|
+
if (marker.map) marker.map = null;
|
|
64
|
+
});
|
|
65
|
+
mapInstanceRef.current = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Import marker library for AdvancedMarkerElement
|
|
69
|
+
const { AdvancedMarkerElement } = await window.google.maps.importLibrary("marker") as any;
|
|
70
|
+
|
|
71
|
+
// Create geocoder - now we know it's available
|
|
72
|
+
geocoderRef.current = new window.google.maps.Geocoder();
|
|
73
|
+
|
|
74
|
+
// Store AdvancedMarkerElement class for use in callback
|
|
75
|
+
const MarkerClass = AdvancedMarkerElement;
|
|
76
|
+
|
|
77
|
+
// Geocode the address
|
|
78
|
+
geocoderRef.current.geocode({ address }, (results: any[], status: string) => {
|
|
79
|
+
// Check if component is still mounted and ref is still valid
|
|
80
|
+
if (!mapRef.current || !document.body.contains(mapRef.current)) {
|
|
81
|
+
isInitializingRef.current = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (status === 'OK' && results && results[0]) {
|
|
86
|
+
const location = results[0].geometry.location;
|
|
87
|
+
|
|
88
|
+
// Create map centered on the geocoded location
|
|
89
|
+
// Advanced markers require a mapId
|
|
90
|
+
// Note: When using mapId, styles are controlled via Google Cloud Console, not in code
|
|
91
|
+
const map = new window.google.maps.Map(mapRef.current, {
|
|
92
|
+
zoom: 15,
|
|
93
|
+
center: location,
|
|
94
|
+
mapId: 'DEMO_MAP_ID', // Can be replaced with a custom Map ID from Google Cloud Console
|
|
95
|
+
mapTypeControl: false,
|
|
96
|
+
streetViewControl: false,
|
|
97
|
+
fullscreenControl: true,
|
|
98
|
+
zoomControl: true,
|
|
99
|
+
// Styles cannot be set when mapId is present - configure in Google Cloud Console instead
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Create AdvancedMarkerElement (modern, non-deprecated approach)
|
|
103
|
+
const marker = new MarkerClass({
|
|
104
|
+
map: map,
|
|
105
|
+
position: location,
|
|
106
|
+
title: locationName || address,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Store markers for cleanup
|
|
110
|
+
map.markers = [marker];
|
|
111
|
+
mapInstanceRef.current = map;
|
|
112
|
+
setIsLoaded(true);
|
|
113
|
+
setMapError(null);
|
|
114
|
+
} else {
|
|
115
|
+
// If geocoding fails, show error
|
|
116
|
+
setMapError('Unable to find the address. Please check the address and try again.');
|
|
117
|
+
setIsLoaded(false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isInitializingRef.current = false;
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('Error initializing map:', error);
|
|
124
|
+
setMapError('Error loading map. Please try again later.');
|
|
125
|
+
isInitializingRef.current = false;
|
|
126
|
+
}
|
|
127
|
+
}, [address, locationName]);
|
|
128
|
+
|
|
129
|
+
// Load Google Maps script with proper callback
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
let mounted = true;
|
|
132
|
+
let checkInterval: NodeJS.Timeout | null = null;
|
|
133
|
+
|
|
134
|
+
// Generate unique callback name for this component instance
|
|
135
|
+
const callbackName = `initGoogleMap_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
136
|
+
callbackNameRef.current = callbackName;
|
|
137
|
+
|
|
138
|
+
// Create callback function that will be called when API loads
|
|
139
|
+
(window as any)[callbackName] = () => {
|
|
140
|
+
if (mounted && mapRef.current) {
|
|
141
|
+
// Small delay to ensure everything is ready
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
if (mounted && mapRef.current) {
|
|
144
|
+
initMap();
|
|
145
|
+
}
|
|
146
|
+
}, 100);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Check if script is already loaded
|
|
151
|
+
if (window.google && window.google.maps && window.google.maps.Geocoder) {
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
if (mounted && mapRef.current) {
|
|
154
|
+
initMap();
|
|
155
|
+
}
|
|
156
|
+
}, 100);
|
|
157
|
+
return () => {
|
|
158
|
+
mounted = false;
|
|
159
|
+
delete (window as any)[callbackName];
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check if script tag already exists (shared script)
|
|
164
|
+
const existingScript = document.querySelector(`script[src*="maps.googleapis.com"]`) as HTMLScriptElement;
|
|
165
|
+
if (existingScript) {
|
|
166
|
+
// Script is loading or loaded, wait for it
|
|
167
|
+
checkInterval = setInterval(() => {
|
|
168
|
+
if (window.google && window.google.maps && window.google.maps.Geocoder && mounted) {
|
|
169
|
+
clearInterval(checkInterval!);
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
if (mounted && mapRef.current) {
|
|
172
|
+
initMap();
|
|
173
|
+
}
|
|
174
|
+
}, 100);
|
|
175
|
+
}
|
|
176
|
+
}, 100);
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
mounted = false;
|
|
180
|
+
if (checkInterval) {
|
|
181
|
+
clearInterval(checkInterval);
|
|
182
|
+
}
|
|
183
|
+
delete (window as any)[callbackName];
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create and load script with callback
|
|
188
|
+
// Include 'marker' library for AdvancedMarkerElement
|
|
189
|
+
const script = document.createElement('script');
|
|
190
|
+
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places,marker&loading=async&callback=${callbackName}`;
|
|
191
|
+
script.async = true;
|
|
192
|
+
script.defer = true;
|
|
193
|
+
|
|
194
|
+
script.onerror = () => {
|
|
195
|
+
if (mounted) {
|
|
196
|
+
setMapError('Failed to load Google Maps. Please check your internet connection.');
|
|
197
|
+
delete (window as any)[callbackName];
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
document.head.appendChild(script);
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
mounted = false;
|
|
205
|
+
if (checkInterval) {
|
|
206
|
+
clearInterval(checkInterval);
|
|
207
|
+
}
|
|
208
|
+
// Clean up callback
|
|
209
|
+
if (callbackNameRef.current) {
|
|
210
|
+
delete (window as any)[callbackNameRef.current];
|
|
211
|
+
callbackNameRef.current = null;
|
|
212
|
+
}
|
|
213
|
+
// Never remove the script - it's shared and removing it causes React errors
|
|
214
|
+
};
|
|
215
|
+
}, []); // Only run once on mount
|
|
216
|
+
|
|
217
|
+
// Re-initialize map if address changes (but only after API is loaded)
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (window.google && window.google.maps && window.google.maps.Geocoder && mapRef.current && !isInitializingRef.current) {
|
|
220
|
+
// Small delay to ensure DOM is stable
|
|
221
|
+
const timeoutId = setTimeout(() => {
|
|
222
|
+
if (mapRef.current) {
|
|
223
|
+
initMap();
|
|
224
|
+
}
|
|
225
|
+
}, 100);
|
|
226
|
+
|
|
227
|
+
return () => clearTimeout(timeoutId);
|
|
228
|
+
}
|
|
229
|
+
}, [address, locationName, initMap]);
|
|
230
|
+
|
|
231
|
+
// Cleanup map instance on unmount
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
return () => {
|
|
234
|
+
if (mapInstanceRef.current) {
|
|
235
|
+
// Clear markers
|
|
236
|
+
const markers = mapInstanceRef.current.markers || [];
|
|
237
|
+
markers.forEach((marker: any) => {
|
|
238
|
+
if (marker.map) marker.map = null;
|
|
239
|
+
});
|
|
240
|
+
mapInstanceRef.current = null;
|
|
241
|
+
}
|
|
242
|
+
isInitializingRef.current = false;
|
|
243
|
+
};
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div className={`h-full w-full rounded-lg ${className}`} style={{ minHeight: '240px', position: 'relative' }}>
|
|
248
|
+
{/* Map container - always present, Google Maps will render into it */}
|
|
249
|
+
<div
|
|
250
|
+
ref={mapRef}
|
|
251
|
+
className="h-full w-full rounded-lg"
|
|
252
|
+
style={{ minHeight: '240px' }}
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
{/* Loading overlay - positioned absolutely to not interfere with map DOM */}
|
|
256
|
+
{!isLoaded && !mapError && (
|
|
257
|
+
<div
|
|
258
|
+
className="absolute inset-0 bg-secondary_alt rounded-lg flex items-center justify-center z-10"
|
|
259
|
+
style={{ pointerEvents: 'none' }}
|
|
260
|
+
>
|
|
261
|
+
<p className="text-tertiary">Loading map...</p>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
{/* Error overlay - positioned absolutely to not interfere with map DOM */}
|
|
266
|
+
{mapError && (
|
|
267
|
+
<div
|
|
268
|
+
className="absolute inset-0 bg-secondary_alt rounded-lg flex items-center justify-center z-10"
|
|
269
|
+
style={{ pointerEvents: 'auto' }}
|
|
270
|
+
>
|
|
271
|
+
<div className="text-center p-4">
|
|
272
|
+
<p className="text-tertiary mb-2">{mapError}</p>
|
|
273
|
+
<a
|
|
274
|
+
href={`https://maps.google.com/?q=${encodeURIComponent(address)}`}
|
|
275
|
+
target="_blank"
|
|
276
|
+
rel="noopener noreferrer"
|
|
277
|
+
className="text-sm text-brand-secondary hover:underline"
|
|
278
|
+
>
|
|
279
|
+
Open in Google Maps
|
|
280
|
+
</a>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
|
|
6
|
+
interface MarkdownRendererProps {
|
|
7
|
+
content: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a URL-friendly ID from a heading text
|
|
13
|
+
*/
|
|
14
|
+
function slugify(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
19
|
+
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
|
|
20
|
+
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract text content from React children (handles nested structures)
|
|
25
|
+
*/
|
|
26
|
+
function extractTextFromChildren(children: any): string {
|
|
27
|
+
if (typeof children === 'string') {
|
|
28
|
+
return children;
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(children)) {
|
|
31
|
+
return children.map((child) => extractTextFromChildren(child)).join('');
|
|
32
|
+
}
|
|
33
|
+
if (children && typeof children === 'object' && 'props' in children) {
|
|
34
|
+
return extractTextFromChildren(children.props.children);
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
|
40
|
+
return (
|
|
41
|
+
<div className={className}>
|
|
42
|
+
<ReactMarkdown
|
|
43
|
+
remarkPlugins={[remarkGfm]}
|
|
44
|
+
components={{
|
|
45
|
+
// Headings with IDs for table of contents
|
|
46
|
+
h1: ({ node, children, ...props }: any) => {
|
|
47
|
+
const text = extractTextFromChildren(children);
|
|
48
|
+
const id = slugify(text);
|
|
49
|
+
return <h1 id={id} className="text-3xl md:text-4xl font-bold text-gray-900 mb-6" {...props}>{children}</h1>;
|
|
50
|
+
},
|
|
51
|
+
h2: ({ node, children, ...props }: any) => {
|
|
52
|
+
const text = extractTextFromChildren(children);
|
|
53
|
+
const id = slugify(text);
|
|
54
|
+
return <h2 id={id} className="text-2xl font-semibold text-gray-900 mb-4" {...props}>{children}</h2>;
|
|
55
|
+
},
|
|
56
|
+
h3: ({ node, children, ...props }: any) => {
|
|
57
|
+
const text = extractTextFromChildren(children);
|
|
58
|
+
const id = slugify(text);
|
|
59
|
+
return <h3 id={id} className="text-xl font-semibold text-gray-900 mb-3" {...props}>{children}</h3>;
|
|
60
|
+
},
|
|
61
|
+
h4: ({ node, children, ...props }: any) => {
|
|
62
|
+
const text = extractTextFromChildren(children);
|
|
63
|
+
const id = slugify(text);
|
|
64
|
+
return <h4 id={id} className="text-lg font-semibold text-gray-900 mb-2" {...props}>{children}</h4>;
|
|
65
|
+
},
|
|
66
|
+
h5: ({ node, children, ...props }: any) => {
|
|
67
|
+
const text = extractTextFromChildren(children);
|
|
68
|
+
const id = slugify(text);
|
|
69
|
+
return <h5 id={id} className="text-base font-semibold text-gray-900 mb-2" {...props}>{children}</h5>;
|
|
70
|
+
},
|
|
71
|
+
h6: ({ node, children, ...props }: any) => {
|
|
72
|
+
const text = extractTextFromChildren(children);
|
|
73
|
+
const id = slugify(text);
|
|
74
|
+
return <h6 id={id} className="text-sm font-semibold text-gray-900 mb-2" {...props}>{children}</h6>;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Paragraphs and text
|
|
78
|
+
p: ({ node, ...props }) => (
|
|
79
|
+
<p className="text-gray-700 leading-relaxed mb-4 last:mb-0" {...props} />
|
|
80
|
+
),
|
|
81
|
+
strong: ({ node, ...props }) => (
|
|
82
|
+
<strong className="font-semibold text-gray-900" {...props} />
|
|
83
|
+
),
|
|
84
|
+
em: ({ node, ...props }) => (
|
|
85
|
+
<em className="italic text-gray-700" {...props} />
|
|
86
|
+
),
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
// Lists
|
|
90
|
+
ul: ({ node, ...props }) => (
|
|
91
|
+
<ul className="list-disc list-inside space-y-1 mb-4 last:mb-0" {...props} />
|
|
92
|
+
),
|
|
93
|
+
ol: ({ node, ...props }) => (
|
|
94
|
+
<ol className="list-decimal list-inside space-y-1 mb-4 last:mb-0" {...props} />
|
|
95
|
+
),
|
|
96
|
+
li: ({ node, ...props }) => (
|
|
97
|
+
<li className="text-gray-700 leading-relaxed" {...props} />
|
|
98
|
+
),
|
|
99
|
+
|
|
100
|
+
// Blockquotes
|
|
101
|
+
blockquote: ({ node, ...props }) => (
|
|
102
|
+
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600 bg-blue-50 py-2 mb-4" {...props} />
|
|
103
|
+
),
|
|
104
|
+
|
|
105
|
+
// Links
|
|
106
|
+
a: ({ node, ...props }) => (
|
|
107
|
+
<a className="text-blue-600 hover:text-blue-800 hover:underline" {...props} />
|
|
108
|
+
),
|
|
109
|
+
|
|
110
|
+
// Images
|
|
111
|
+
img: ({ node, ...props }) => (
|
|
112
|
+
<img className="max-w-full h-auto rounded-lg shadow-md my-4" {...props} />
|
|
113
|
+
),
|
|
114
|
+
|
|
115
|
+
// Horizontal rules
|
|
116
|
+
hr: () => <hr className="my-6 border-gray-200" />,
|
|
117
|
+
|
|
118
|
+
// Tables
|
|
119
|
+
table: ({ node, ...props }) => (
|
|
120
|
+
<div className="overflow-x-auto my-4">
|
|
121
|
+
<table className="min-w-full bg-white border border-gray-200 rounded-lg" {...props} />
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
thead: ({ node, ...props }) => (
|
|
125
|
+
<thead className="bg-gray-50" {...props} />
|
|
126
|
+
),
|
|
127
|
+
tbody: ({ node, ...props }) => (
|
|
128
|
+
<tbody {...props} />
|
|
129
|
+
),
|
|
130
|
+
tr: ({ node, ...props }) => (
|
|
131
|
+
<tr className="border-b border-gray-200" {...props} />
|
|
132
|
+
),
|
|
133
|
+
th: ({ node, ...props }) => (
|
|
134
|
+
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900" {...props} />
|
|
135
|
+
),
|
|
136
|
+
td: ({ node, ...props }) => (
|
|
137
|
+
<td className="px-4 py-3 text-sm text-gray-700" {...props} />
|
|
138
|
+
),
|
|
139
|
+
|
|
140
|
+
// Code blocks
|
|
141
|
+
pre: ({ node, ...props }) => (
|
|
142
|
+
<pre className="bg-gray-100 p-4 rounded-lg overflow-x-auto my-4" {...props} />
|
|
143
|
+
),
|
|
144
|
+
|
|
145
|
+
// Code
|
|
146
|
+
code: ({ node, ...props }) => (
|
|
147
|
+
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800" {...props} />
|
|
148
|
+
),
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{content}
|
|
152
|
+
</ReactMarkdown>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
|
|
4
|
+
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
|
5
|
+
import { cx } from '../../../utils/cx';
|
|
6
|
+
|
|
7
|
+
export const DialogTrigger = AriaDialogTrigger;
|
|
8
|
+
|
|
9
|
+
export const ModalOverlay = (props: AriaModalOverlayProps) => {
|
|
10
|
+
return (
|
|
11
|
+
<AriaModalOverlay
|
|
12
|
+
{...props}
|
|
13
|
+
className={(state) =>
|
|
14
|
+
cx(
|
|
15
|
+
"fixed inset-0 z-50 flex min-h-dvh w-full items-end justify-center overflow-y-auto bg-overlay/70 px-4 pt-4 pb-[clamp(16px,8vh,64px)] outline-hidden backdrop-blur-[6px] sm:items-center sm:justify-center sm:p-8",
|
|
16
|
+
state.isEntering && "duration-300 ease-out animate-in fade-in",
|
|
17
|
+
state.isExiting && "duration-200 ease-in animate-out fade-out",
|
|
18
|
+
typeof props.className === "function" ? props.className(state) : props.className,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Modal = (props: AriaModalOverlayProps) => (
|
|
26
|
+
<AriaModal
|
|
27
|
+
{...props}
|
|
28
|
+
className={(state) =>
|
|
29
|
+
cx(
|
|
30
|
+
"max-h-full w-full align-middle outline-hidden max-sm:overflow-y-auto max-sm:rounded-xl",
|
|
31
|
+
state.isEntering && "duration-300 ease-out animate-in zoom-in-95",
|
|
32
|
+
state.isExiting && "duration-200 ease-in animate-out zoom-out-95",
|
|
33
|
+
typeof props.className === "function" ? props.className(state) : props.className,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const Dialog = (props: AriaDialogProps) => (
|
|
40
|
+
<AriaDialog {...props} className={cx("flex w-full items-center justify-center outline-hidden", props.className)} />
|
|
41
|
+
);
|