fix contect page
This commit is contained in:
51
package-lock.json
generated
51
package-lock.json
generated
@@ -14,10 +14,12 @@
|
|||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"three": "^0.171.0"
|
"three": "^0.171.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -2277,6 +2280,17 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-three/drei": {
|
"node_modules/@react-three/drei": {
|
||||||
"version": "10.7.7",
|
"version": "10.7.7",
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||||
@@ -2903,6 +2917,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2996,6 +3017,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.41",
|
"version": "20.19.41",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||||
@@ -8225,6 +8256,12 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lenis": {
|
"node_modules/lenis": {
|
||||||
"version": "1.3.23",
|
"version": "1.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
||||||
@@ -9662,6 +9699,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-use-measure": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
|
|||||||
@@ -19,10 +19,12 @@
|
|||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"three": "^0.171.0"
|
"three": "^0.171.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
22
src/components/map/ContactMapEmbed.tsx
Normal file
22
src/components/map/ContactMapEmbed.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContactMapEmbed
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Client boundary that lazy-loads the Leaflet map. `ssr: false` keeps Leaflet
|
||||||
|
* out of the server bundle and off the critical render path; the skeleton fills
|
||||||
|
* the host container's fixed height so there is zero layout shift (CLS) while
|
||||||
|
* the map chunk loads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import styles from "./OfficeMap.module.css";
|
||||||
|
|
||||||
|
const OfficeMap = dynamic(() => import("./OfficeMap"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className={styles.skeleton} role="presentation" aria-hidden="true" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ContactMapEmbed() {
|
||||||
|
return <OfficeMap />;
|
||||||
|
}
|
||||||
281
src/components/map/OfficeMap.module.css
Normal file
281
src/components/map/OfficeMap.module.css
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/* ===========================================================================
|
||||||
|
Office satellite map — scoped styles.
|
||||||
|
All Leaflet global classes are namespaced under `.root` via :global() so this
|
||||||
|
module cannot leak into the rest of the app and vice-versa.
|
||||||
|
Colours/radii reference the app's theme (dark surface, #C01227 brand red).
|
||||||
|
The map fills its container 100% — no hard-coded layout values live here.
|
||||||
|
=========================================================================== */
|
||||||
|
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* inherit the host container's rounded corners so tiles/controls clip cleanly */
|
||||||
|
border-radius: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0b0b;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0b0b0b;
|
||||||
|
font-family: var(--font-manrope), system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visible keyboard focus ring on the map viewport */
|
||||||
|
.map:focus-visible {
|
||||||
|
outline: 2px solid #c01227;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Office navigation buttons (fly-to controls) ---- */
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
/* Span the full width (with side insets) instead of left:50%+auto-width:
|
||||||
|
an absolutely-positioned auto-width box anchored at left:50% can only grow
|
||||||
|
to 50% of the map, which on a narrow phone forced the city buttons to stack
|
||||||
|
vertically. With left/right insets the row uses the full width and centres. */
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 600; /* above tiles + markers; popups open lower so they never collide */
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none; /* let the row be transparent to drags; buttons re-enable */
|
||||||
|
}
|
||||||
|
.controlBtn {
|
||||||
|
pointer-events: auto;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(15, 15, 17, 0.82);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: var(--font-manrope), system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease,
|
||||||
|
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.controlBtn:hover {
|
||||||
|
background: rgba(192, 18, 39, 0.9);
|
||||||
|
border-color: #c01227;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.controlBtn:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.controlBtnActive,
|
||||||
|
.controlBtnActive:hover {
|
||||||
|
background: #c01227;
|
||||||
|
border-color: #c01227;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 6px 18px rgba(192, 18, 39, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.controls {
|
||||||
|
top: 12px;
|
||||||
|
gap: 5px;
|
||||||
|
/* Keep all three city buttons on a single line — shrink them (below) so the
|
||||||
|
row fits instead of wrapping to two lines on narrow phones. */
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.controlBtn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Branded marker pin ---- */
|
||||||
|
.markerIcon {
|
||||||
|
/* divIcon resets — keep only the pin's own drop shadow */
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.55));
|
||||||
|
transition: transform 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.markerIcon:hover,
|
||||||
|
.markerIcon:focus-visible {
|
||||||
|
transform: translateY(-3px) scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Themed Leaflet internals (scoped to this map only) ---- */
|
||||||
|
.root :global(.leaflet-container) {
|
||||||
|
background: #0b0b0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom + attribution controls — dark, on-theme */
|
||||||
|
.root :global(.leaflet-bar) {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-bar a) {
|
||||||
|
background: rgba(15, 15, 17, 0.92);
|
||||||
|
color: #f5f5f5;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-bar a:hover) {
|
||||||
|
background: #c01227;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-bar a:focus-visible) {
|
||||||
|
outline: 2px solid #c01227;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.leaflet-control-attribution) {
|
||||||
|
background: rgba(10, 10, 10, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px 0 0 0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-control-attribution a) {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popup — dark card matching the app surfaces */
|
||||||
|
.root :global(.leaflet-popup-content-wrapper) {
|
||||||
|
background: #141416;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-content) {
|
||||||
|
margin: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-tip) {
|
||||||
|
background: #141416;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-close-button) {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-close-button:hover) {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-content .office-popup__name) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.root :global(.leaflet-popup-content .office-popup__dot) {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c01227;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Loading skeleton (prevents CLS — fills the fixed-height host) ---- */
|
||||||
|
.skeleton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
100deg,
|
||||||
|
rgba(255, 255, 255, 0) 30%,
|
||||||
|
rgba(255, 255, 255, 0.05) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 70%
|
||||||
|
),
|
||||||
|
#101012;
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton { animation: none; }
|
||||||
|
.markerIcon { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Graceful error fallback ---- */
|
||||||
|
.errorOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: #101012;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-family: var(--font-manrope), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.errorTitle {
|
||||||
|
font-size: clamp(15px, 2.4vw, 18px);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.errorText {
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 38ch;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.errorList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.errorList li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f1f1;
|
||||||
|
}
|
||||||
|
.errorList li::before {
|
||||||
|
content: "";
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c01227;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Screen-reader-only office list (semantic fallback) ---- */
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
264
src/components/map/OfficeMap.tsx
Normal file
264
src/components/map/OfficeMap.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OfficeMap
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Client-only Leaflet satellite map (Esri World Imagery) rendering the three
|
||||||
|
* Doormile office markers, plus a row of "jump to office" buttons that fly the
|
||||||
|
* map to a selected office's coordinates and open its popup.
|
||||||
|
*
|
||||||
|
* Loaded via a `ssr:false` dynamic import so Leaflet (which touches `window`)
|
||||||
|
* never runs on the server and cannot cause hydration mismatches. Layout/spacing
|
||||||
|
* is owned by the host container (see ContactMap).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import styles from "./OfficeMap.module.css";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import L from "leaflet";
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer, ZoomControl, useMap } from "react-leaflet";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ESRI_WORLD_IMAGERY,
|
||||||
|
MAP_FIT_MAX_ZOOM,
|
||||||
|
MAP_FIT_PADDING,
|
||||||
|
MAP_FOCUS_ZOOM,
|
||||||
|
MAP_INITIAL_CENTER,
|
||||||
|
MAP_INITIAL_ZOOM,
|
||||||
|
OFFICE_LOCATIONS,
|
||||||
|
type LatLng,
|
||||||
|
} from "./offices";
|
||||||
|
|
||||||
|
type MapStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
/** A request to focus a specific office. `nonce` lets the same office be re-selected. */
|
||||||
|
type FocusTarget = { id: string; nonce: number };
|
||||||
|
|
||||||
|
/** Build the branded SVG pin once (module scope is fine — this file is client-only). */
|
||||||
|
function createMarkerIcon(): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: styles.markerIcon,
|
||||||
|
html: `
|
||||||
|
<svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M15 0C6.72 0 0 6.72 0 15c0 10.5 13.06 23.86 13.62 24.42a1.95 1.95 0 0 0 2.76 0C16.94 38.86 30 25.5 30 15 30 6.72 23.28 0 15 0Z" fill="#C01227"/>
|
||||||
|
<path d="M15 1.5C7.54 1.5 1.5 7.54 1.5 15c0 9.6 12.3 22.4 12.83 22.94a.95.95 0 0 0 1.34 0C16.2 37.4 28.5 24.6 28.5 15 28.5 7.54 22.46 1.5 15 1.5Z" fill="none" stroke="#ffffff" stroke-width="1.2" stroke-opacity="0.85"/>
|
||||||
|
<circle cx="15" cy="15" r="5.4" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
iconSize: [30, 40],
|
||||||
|
iconAnchor: [15, 40],
|
||||||
|
popupAnchor: [0, -36],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative map effects that need the Leaflet instance:
|
||||||
|
* - fit the viewport to every marker (with edge padding)
|
||||||
|
* - keep that framing correct across resizes / lazy reveals
|
||||||
|
* - fly to a single office when one is selected via the buttons
|
||||||
|
*/
|
||||||
|
function MapController({
|
||||||
|
positions,
|
||||||
|
focus,
|
||||||
|
markerRefs,
|
||||||
|
}: {
|
||||||
|
positions: LatLng[];
|
||||||
|
focus: FocusTarget | null;
|
||||||
|
markerRefs: React.RefObject<Record<string, L.Marker>>;
|
||||||
|
}) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
// Latest focus, read inside the (stable) resize handler without resubscribing.
|
||||||
|
const focusRef = useRef(focus);
|
||||||
|
useEffect(() => {
|
||||||
|
focusRef.current = focus;
|
||||||
|
}, [focus]);
|
||||||
|
|
||||||
|
const fitAll = useCallback(() => {
|
||||||
|
if (positions.length === 0) return;
|
||||||
|
map.invalidateSize();
|
||||||
|
map.fitBounds(L.latLngBounds(positions), {
|
||||||
|
padding: MAP_FIT_PADDING,
|
||||||
|
maxZoom: MAP_FIT_MAX_ZOOM,
|
||||||
|
});
|
||||||
|
}, [map, positions]);
|
||||||
|
|
||||||
|
// Initial fit, after the container has its final size.
|
||||||
|
useEffect(() => {
|
||||||
|
const raf = requestAnimationFrame(fitAll);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [fitAll]);
|
||||||
|
|
||||||
|
// Re-measure on container resize; only re-frame all markers when nothing is
|
||||||
|
// focused, so resizing while zoomed into one office doesn't jump the view away.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = map.getContainer();
|
||||||
|
let raf = 0;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
if (!focusRef.current) {
|
||||||
|
map.fitBounds(L.latLngBounds(positions), {
|
||||||
|
padding: MAP_FIT_PADDING,
|
||||||
|
maxZoom: MAP_FIT_MAX_ZOOM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(container);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [map, positions]);
|
||||||
|
|
||||||
|
// Fly to the selected office, then open its popup once movement settles.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focus) return;
|
||||||
|
const office = OFFICE_LOCATIONS.find((item) => item.id === focus.id);
|
||||||
|
if (!office) return;
|
||||||
|
|
||||||
|
map.flyTo(office.position, MAP_FOCUS_ZOOM, { duration: 1.1 });
|
||||||
|
|
||||||
|
const marker = markerRefs.current[office.id];
|
||||||
|
if (!marker) return;
|
||||||
|
const openPopup = () => marker.openPopup();
|
||||||
|
map.once("moveend", openPopup);
|
||||||
|
return () => {
|
||||||
|
map.off("moveend", openPopup);
|
||||||
|
};
|
||||||
|
}, [map, focus, markerRefs]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OfficeMap() {
|
||||||
|
const icon = useMemo(() => createMarkerIcon(), []);
|
||||||
|
const positions = useMemo<LatLng[]>(
|
||||||
|
() => OFFICE_LOCATIONS.map((office) => office.position),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const markerRefs = useRef<Record<string, L.Marker>>({});
|
||||||
|
|
||||||
|
const [focus, setFocus] = useState<FocusTarget | null>(null);
|
||||||
|
const focusOffice = useCallback((id: string) => {
|
||||||
|
setFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<MapStatus>("loading");
|
||||||
|
const loadedRef = useRef(false);
|
||||||
|
const errorCountRef = useRef(0);
|
||||||
|
|
||||||
|
const handleTileLoad = useCallback(() => {
|
||||||
|
loadedRef.current = true;
|
||||||
|
setStatus("ready");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only surface an error if tiles never render (network/CORS/down). Once any
|
||||||
|
// tile load succeeds the map is considered healthy and stays that way.
|
||||||
|
const handleTileError = useCallback(() => {
|
||||||
|
errorCountRef.current += 1;
|
||||||
|
if (!loadedRef.current && errorCountRef.current >= 6) setStatus("error");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
if (!loadedRef.current) setStatus("error");
|
||||||
|
}, 12_000);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root} role="region" aria-label="Map of Doormile office locations">
|
||||||
|
{/* Semantic, always-available fallback for assistive tech + no-JS/SEO. */}
|
||||||
|
<ul className={styles.srOnly}>
|
||||||
|
{OFFICE_LOCATIONS.map((office) => (
|
||||||
|
<li key={office.id}>
|
||||||
|
{office.name} — latitude {office.position[0]}, longitude {office.position[1]}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Jump-to-office navigation buttons. */}
|
||||||
|
<div className={styles.controls} role="group" aria-label="Jump to an office location">
|
||||||
|
{OFFICE_LOCATIONS.map((office) => {
|
||||||
|
const isActive = focus?.id === office.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={office.id}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.controlBtn} ${isActive ? styles.controlBtnActive : ""}`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-label={`Show ${office.name} on the map`}
|
||||||
|
onClick={() => focusOffice(office.id)}
|
||||||
|
>
|
||||||
|
{office.city}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapContainer
|
||||||
|
className={styles.map}
|
||||||
|
center={MAP_INITIAL_CENTER}
|
||||||
|
zoom={MAP_INITIAL_ZOOM}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
|
worldCopyJump
|
||||||
|
>
|
||||||
|
{/* Keep zoom controls clear of the top-row buttons. */}
|
||||||
|
<ZoomControl position="bottomleft" />
|
||||||
|
|
||||||
|
<TileLayer
|
||||||
|
url={ESRI_WORLD_IMAGERY.url}
|
||||||
|
attribution={ESRI_WORLD_IMAGERY.attribution}
|
||||||
|
maxZoom={ESRI_WORLD_IMAGERY.maxZoom}
|
||||||
|
updateWhenIdle
|
||||||
|
keepBuffer={2}
|
||||||
|
eventHandlers={{ load: handleTileLoad, tileerror: handleTileError }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{OFFICE_LOCATIONS.map((office) => (
|
||||||
|
<Marker
|
||||||
|
key={office.id}
|
||||||
|
position={office.position}
|
||||||
|
icon={icon}
|
||||||
|
keyboard
|
||||||
|
title={office.name}
|
||||||
|
alt={office.name}
|
||||||
|
eventHandlers={{ click: () => focusOffice(office.id) }}
|
||||||
|
ref={(instance) => {
|
||||||
|
if (instance) markerRefs.current[office.id] = instance;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<span className="office-popup__name">
|
||||||
|
<span className="office-popup__dot" aria-hidden="true" />
|
||||||
|
{office.name}
|
||||||
|
</span>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MapController positions={positions} focus={focus} markerRefs={markerRefs} />
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className={styles.errorOverlay} role="alert">
|
||||||
|
<p className={styles.errorTitle}>Map could not be loaded</p>
|
||||||
|
<p className={styles.errorText}>
|
||||||
|
Please check your connection. Our offices are located in:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.errorList}>
|
||||||
|
{OFFICE_LOCATIONS.map((office) => (
|
||||||
|
<li key={office.id}>{office.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/map/offices.ts
Normal file
59
src/components/map/offices.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Office location data + map tile configuration for the Contact section map.
|
||||||
|
*
|
||||||
|
* Kept dependency-free (no Leaflet runtime import) so it can be safely consumed
|
||||||
|
* by both server and client modules. Types model `[lat, lng]` tuples that are
|
||||||
|
* directly compatible with Leaflet's `LatLngExpression`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A `[latitude, longitude]` coordinate pair. */
|
||||||
|
export type LatLng = [number, number];
|
||||||
|
|
||||||
|
export interface OfficeLocation {
|
||||||
|
/** Stable, unique key (used for React keys + analytics). */
|
||||||
|
readonly id: string;
|
||||||
|
/** Short city label, shown on the navigation buttons. */
|
||||||
|
readonly city: string;
|
||||||
|
/** Human-readable label shown in the marker popup + a11y fallback. */
|
||||||
|
readonly name: string;
|
||||||
|
/** `[latitude, longitude]`. */
|
||||||
|
readonly position: LatLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three permanent office markers, ordered north-to-south is irrelevant — bounds are auto-fit. */
|
||||||
|
export const OFFICE_LOCATIONS: readonly OfficeLocation[] = [
|
||||||
|
{ id: "coimbatore", city: "Coimbatore", name: "Coimbatore Office", position: [11.0168, 76.9558] },
|
||||||
|
{ id: "bengaluru", city: "Bengaluru", name: "Bengaluru Office", position: [12.9716, 77.5946] },
|
||||||
|
{ id: "hyderabad", city: "Hyderabad", name: "Hyderabad Office", position: [17.385, 78.4867] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface TileLayerConfig {
|
||||||
|
readonly url: string;
|
||||||
|
readonly attribution: string;
|
||||||
|
readonly maxZoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esri "World Imagery" satellite basemap.
|
||||||
|
* Attribution is mandatory per Esri's terms of use.
|
||||||
|
* @see https://www.arcgis.com/home/item.html?id=10df2279f9684e4a9f6a7f08febac2a9
|
||||||
|
*/
|
||||||
|
export const ESRI_WORLD_IMAGERY: TileLayerConfig = {
|
||||||
|
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
attribution:
|
||||||
|
'Imagery © <a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>, Maxar, Earthstar Geographics & the GIS User Community',
|
||||||
|
maxZoom: 19,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Padding (in px) applied when auto-fitting bounds so markers never touch the edges. */
|
||||||
|
export const MAP_FIT_PADDING: LatLng = [50, 50];
|
||||||
|
|
||||||
|
/** Cap the auto-fit zoom so two close offices don't zoom the map in too far. */
|
||||||
|
export const MAP_FIT_MAX_ZOOM = 7;
|
||||||
|
|
||||||
|
/** City-level zoom used when a single office is selected via the nav buttons. */
|
||||||
|
export const MAP_FOCUS_ZOOM = 13;
|
||||||
|
|
||||||
|
/** Initial center/zoom (roughly the centroid of the offices) used before bounds are fit. */
|
||||||
|
export const MAP_INITIAL_CENTER: LatLng = [14.0, 77.7];
|
||||||
|
export const MAP_INITIAL_ZOOM = 5;
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import ContactMapEmbed from "@/components/map/ContactMapEmbed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact / Location section.
|
||||||
|
*
|
||||||
|
* The section's layout (20px side padding, 40px top margin, the 25px top-rounded
|
||||||
|
* card that flows into the footer, and the 500px / 360px responsive heights) is
|
||||||
|
* preserved verbatim from the original Elementor markup so there is no visual
|
||||||
|
* regression. Only the *content* of the embed has changed: the Google Maps
|
||||||
|
* iframe is replaced by an interactive Leaflet satellite map (ContactMapEmbed).
|
||||||
|
*/
|
||||||
export default function ContactMap() {
|
export default function ContactMap() {
|
||||||
return (
|
return (
|
||||||
<div className="elementor-element elementor-element-7304a53 e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="7304a53" data-element_type="container" data-e-type="container">
|
<div className="elementor-element elementor-element-7304a53 e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="7304a53" data-element_type="container" data-e-type="container">
|
||||||
@@ -7,36 +17,47 @@ export default function ContactMap() {
|
|||||||
.elementor-element-7304a53 {
|
.elementor-element-7304a53 {
|
||||||
--padding-left: 20px;
|
--padding-left: 20px;
|
||||||
--padding-right: 20px;
|
--padding-right: 20px;
|
||||||
--margin-top: 40px;
|
--margin-top: 12px;
|
||||||
--margin-bottom: 0px;
|
--margin-bottom: 0px;
|
||||||
|
/* Reduce the large gap above the map (was 40px). */
|
||||||
|
margin-top: 12px !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
/* Real side padding so the map is inset like the hero/footer sections
|
||||||
|
(the --padding-* vars only work with elementor-frontend.css, which
|
||||||
|
isn't loaded, so the map was going edge-to-edge). Matches
|
||||||
|
.custom-standard-hero-container: 20px desktop / 10px mobile. */
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* The section computes to display:inline here (the --display:flex var
|
||||||
|
isn't mapped without elementor-frontend.css), so its padding never
|
||||||
|
constrained the block child. Force flex so the padding insets the map. */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.elementor-element-7304a53 .elementor-custom-embed {
|
.elementor-element-7304a53 .elementor-custom-embed {
|
||||||
border-radius: 25px 25px 0 0;
|
/* Rounded on all corners so the map reads as a self-contained card
|
||||||
|
(bottom was square before, leaving a hard edge above the footer gap). */
|
||||||
|
border-radius: 25px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #ededed;
|
background: #0b0b0b;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
.elementor-element-7304a53 .elementor-custom-embed iframe {
|
@media (max-width: 840px) {
|
||||||
display: block;
|
.elementor-element-7304a53 {
|
||||||
filter: grayscale(100%);
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.elementor-element-7304a53 .elementor-custom-embed { height: 360px !important; }
|
.elementor-element-7304a53 .elementor-custom-embed { height: 360px !important; }
|
||||||
}
|
}
|
||||||
` }} />
|
` }} />
|
||||||
<div className="elementor-element elementor-element-5a3eed4 elementor-widget elementor-widget-google_maps" data-id="5a3eed4" data-element_type="widget" data-e-type="widget" data-widget_type="google_maps.default">
|
<div className="elementor-element elementor-element-5a3eed4 elementor-widget" data-id="5a3eed4" data-element_type="widget" data-e-type="widget">
|
||||||
<div className="elementor-widget-container">
|
<div className="elementor-widget-container">
|
||||||
<div className="elementor-custom-embed" style={{ width: "100%", height: "500px" }}>
|
<div className="elementor-custom-embed" style={{ width: "100%", height: "500px" }}>
|
||||||
<iframe
|
<ContactMapEmbed />
|
||||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3806.1918122409634!2d78.35579498480733!3d17.45053110831999!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3bcb93b8c5a049b3%3A0x6f4b5999fccad985!2sJayabheri%20Enclave%2C%20Gachibowli%2C%20Hyderabad%2C%20Telangana!5e0!3m2!1sen!2sin!4v1778663239768!5m2!1sen!2sin"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{ border: 0 }}
|
|
||||||
allowFullScreen={true}
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
|
||||||
title="Doormile Location Map"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user