diff --git a/package-lock.json b/package-lock.json
index dc5039d..0cc86a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,10 +14,12 @@
"@react-three/postprocessing": "^3.0.4",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
+ "leaflet": "^1.9.4",
"lenis": "^1.3.23",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0",
"three": "^0.171.0"
},
"devDependencies": {
@@ -25,6 +27,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/jest": "^30.0.0",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -2277,6 +2280,17 @@
"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": {
"version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
@@ -2903,6 +2917,13 @@
"dev": true,
"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": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2996,6 +3017,16 @@
"dev": true,
"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": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -8225,6 +8256,12 @@
"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": {
"version": "1.3.23",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
@@ -9662,6 +9699,20 @@
"dev": true,
"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": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
diff --git a/package.json b/package.json
index 722c841..4dde4b8 100644
--- a/package.json
+++ b/package.json
@@ -19,10 +19,12 @@
"@react-three/postprocessing": "^3.0.4",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
+ "leaflet": "^1.9.4",
"lenis": "^1.3.23",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-leaflet": "^5.0.0",
"three": "^0.171.0"
},
"devDependencies": {
@@ -30,6 +32,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/jest": "^30.0.0",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/src/components/map/ContactMapEmbed.tsx b/src/components/map/ContactMapEmbed.tsx
new file mode 100644
index 0000000..44a57f8
--- /dev/null
+++ b/src/components/map/ContactMapEmbed.tsx
@@ -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: () =>
,
+});
+
+export default function ContactMapEmbed() {
+ return ;
+}
diff --git a/src/components/map/OfficeMap.module.css b/src/components/map/OfficeMap.module.css
new file mode 100644
index 0000000..03d7de4
--- /dev/null
+++ b/src/components/map/OfficeMap.module.css
@@ -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;
+}
diff --git a/src/components/map/OfficeMap.tsx b/src/components/map/OfficeMap.tsx
new file mode 100644
index 0000000..72b6978
--- /dev/null
+++ b/src/components/map/OfficeMap.tsx
@@ -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: `
+
+
+
+
+
+ `,
+ 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>;
+}) {
+ 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(
+ () => OFFICE_LOCATIONS.map((office) => office.position),
+ [],
+ );
+ const markerRefs = useRef>({});
+
+ const [focus, setFocus] = useState(null);
+ const focusOffice = useCallback((id: string) => {
+ setFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }));
+ }, []);
+
+ const [status, setStatus] = useState("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 (
+
+ {/* Semantic, always-available fallback for assistive tech + no-JS/SEO. */}
+
+ {OFFICE_LOCATIONS.map((office) => (
+
+ {office.name} — latitude {office.position[0]}, longitude {office.position[1]}
+
+ ))}
+
+
+ {/* Jump-to-office navigation buttons. */}
+
+ {OFFICE_LOCATIONS.map((office) => {
+ const isActive = focus?.id === office.id;
+ return (
+ focusOffice(office.id)}
+ >
+ {office.city}
+
+ );
+ })}
+
+
+
+ {/* Keep zoom controls clear of the top-row buttons. */}
+
+
+
+
+ {OFFICE_LOCATIONS.map((office) => (
+ focusOffice(office.id) }}
+ ref={(instance) => {
+ if (instance) markerRefs.current[office.id] = instance;
+ }}
+ >
+
+
+
+ {office.name}
+
+
+
+ ))}
+
+
+
+
+ {status === "error" && (
+
+
Map could not be loaded
+
+ Please check your connection. Our offices are located in:
+
+
+ {OFFICE_LOCATIONS.map((office) => (
+ {office.name}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/map/offices.ts b/src/components/map/offices.ts
new file mode 100644
index 0000000..314e168
--- /dev/null
+++ b/src/components/map/offices.ts
@@ -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 © Esri , 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;
diff --git a/src/components/sections/ContactMap.tsx b/src/components/sections/ContactMap.tsx
index e951d8e..6aea842 100644
--- a/src/components/sections/ContactMap.tsx
+++ b/src/components/sections/ContactMap.tsx
@@ -1,5 +1,15 @@
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() {
return (
@@ -7,36 +17,47 @@ export default function ContactMap() {
.elementor-element-7304a53 {
--padding-left: 20px;
--padding-right: 20px;
- --margin-top: 40px;
+ --margin-top: 12px;
--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 {
- 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;
- background: #ededed;
+ background: #0b0b0b;
line-height: 0;
}
- .elementor-element-7304a53 .elementor-custom-embed iframe {
- display: block;
- filter: grayscale(100%);
+ @media (max-width: 840px) {
+ .elementor-element-7304a53 {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
}
@media (max-width: 768px) {
.elementor-element-7304a53 .elementor-custom-embed { height: 360px !important; }
}
` }} />
-