diff --git a/frontend/bun.lock b/frontend/bun.lock index d243570..59ed9d4 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -15,10 +15,12 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "html5-qrcode": "^2.3.8", + "leaflet": "^1.9.4", "lucide-react": "^1.11.0", "panzoom": "^9.4.4", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -31,6 +33,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.2", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -176,6 +179,8 @@ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], @@ -296,8 +301,12 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -582,6 +591,8 @@ "jsonrepair": ["jsonrepair@3.14.0", "", { "bin": { "jsonrepair": "bin/cli.js" } }, "sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -728,6 +739,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], diff --git a/frontend/package.json b/frontend/package.json index 16080c8..ad44b6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,10 +27,12 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "html5-qrcode": "^2.3.8", + "leaflet": "^1.9.4", "lucide-react": "^1.11.0", "panzoom": "^9.4.4", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -43,6 +45,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.2", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e78343..12d41a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,17 @@ import { lazy, Suspense, useEffect } from "react"; import { BrowserRouter, - Navigate, + Outlet, Route, Routes, useLocation, + useOutletContext, useParams, } from "react-router-dom"; -import { MainLayout } from "./components/layout/MainLayout"; +import { + type AuthOutletContext, + MainLayout, +} from "./components/layout/MainLayout"; import { RequireAuth } from "./components/RequireAuth"; import { AuthProvider } from "./lib/auth-context"; import { MARINAS } from "./lib/marinas"; @@ -54,6 +58,10 @@ const VerifyEmail = lazy(() => import("./pages/VerifyEmail").then((m) => ({ default: m.VerifyEmail })), ); +const MarinaMapPage = lazy(() => + import("./pages/MarinaMapPage").then((m) => ({ default: m.MarinaMapPage })), +); + // scrolls window + the MainLayout inner scroller back to top on route change // so navigating between pages doesn't dump you mid-page function ScrollToTop() { @@ -74,6 +82,7 @@ function ScrollToTop() { // instead of rendering a generic dashboard for "marina admin" etc function MarinaGuard() { const { marinaSlug } = useParams<{ marinaSlug: string }>(); + const outletContext = useOutletContext(); if (!marinaSlug || !(marinaSlug in MARINAS)) { return ( @@ -83,7 +92,7 @@ function MarinaGuard() { ); } - return ; + return ; } // cf access gates admin host at the edge so no in-app auth needed @@ -126,8 +135,6 @@ export function App() { /> )} - } /> - - }> + }> }> - + } /> - - }> - - - - } - /> - - - }> - - - - } - /> - - + }> + }> - + - - } - /> - - - }> - - - - } - /> + } + /> + + + }> + + + + } + /> + + + }> + + + + } + /> + + + }> + + + + } + /> + + + }> + + + + } + /> + { + // Leave back/forward/middle mouse buttons to the browser (e.g. mouse back → history). + if (event.button !== 0) return true; + const target = event.target as HTMLElement | null; if ( diff --git a/frontend/src/app.css b/frontend/src/app.css index 0fc22c7..3bec7d1 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -336,3 +336,128 @@ 0 0 10px rgba(0, 147, 233, 0.4); } } + +.leaflet-container { + font-family: var(--font-body); + background: #e8f4fc; +} + +.leaflet-interactive { + cursor: pointer; +} + +/* Sweden marina picker — hover cards */ +.marina-map-tooltip.leaflet-tooltip { + padding: 0; + border: 1px solid rgb(255 255 255 / 0.65); + border-radius: 1rem; + background: rgb(255 255 255 / 0.92); + box-shadow: + 0 12px 40px rgb(10 37 64 / 0.14), + 0 0 0 1px rgb(0 147 233 / 0.08); + backdrop-filter: blur(16px); + pointer-events: none; +} + +.marina-map-tooltip.leaflet-tooltip-top::before { + border-top-color: rgb(255 255 255 / 0.92); +} + +.marina-map-tip { + min-width: 10.5rem; + padding: 0.65rem 0.85rem 0.7rem; + font-family: var(--font-body); +} + +.marina-map-tip--active { + padding: 0.75rem 0.95rem 0.8rem; +} + +.marina-map-tip__header { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.marina-map-tip__icon { + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + flex-shrink: 0; + border-radius: 9999px; + background: rgb(0 147 233 / 0.12); + color: #0093e9; +} + +.marina-map-tip--soon .marina-map-tip__icon { + background: rgb(10 37 64 / 0.06); + color: rgb(10 37 64 / 0.45); +} + +.marina-map-tip__name { + font-size: 0.875rem; + font-weight: 800; + line-height: 1.2; + color: #0a2540; +} + +.marina-map-tip__region { + margin-top: 0.1rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgb(10 37 64 / 0.45); +} + +.marina-map-tip__tagline { + margin-top: 0.45rem; + font-size: 0.7rem; + font-weight: 500; + line-height: 1.35; + color: rgb(10 37 64 / 0.55); +} + +.marina-map-tip__cta { + margin-top: 0.5rem; + padding-top: 0.45rem; + border-top: 1px solid rgb(10 37 64 / 0.08); + font-size: 0.6rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #0093e9; +} + +.marina-map-tip--soon .marina-map-tip__cta { + color: rgb(10 37 64 / 0.4); +} + +.marina-map-tip--not-planned .marina-map-tip__icon { + background: rgb(239 68 68 / 0.15); + color: #dc2626; +} + +.marina-map-tip--not-planned .marina-map-tip__cta { + color: #dc2626; +} + +.marina-map-tip--active .marina-map-tip__cta { + color: #0093e9; +} + +.marina-map-hint { + font-family: var(--font-body); +} + +/* Clear the floating DockPulse header (safe-area + 1rem + h-16) */ +.leaflet-top.leaflet-left { + top: calc(env(safe-area-inset-top) + 5.75rem); +} + +@media (min-width: 768px) { + .leaflet-top.leaflet-left { + top: 6.25rem; /* md:top-6 + h-16 + gap */ + } +} diff --git a/frontend/src/components/SwedenMarinaMap.tsx b/frontend/src/components/SwedenMarinaMap.tsx new file mode 100644 index 0000000..d6e69fc --- /dev/null +++ b/frontend/src/components/SwedenMarinaMap.tsx @@ -0,0 +1,195 @@ +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { Anchor, MapPin } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + CircleMarker, + MapContainer, + TileLayer, + Tooltip, + useMap, +} from "react-leaflet"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { + getMarinaMapStatus, + MARINA_LIST, + type MarinaInfo, + SWEDEN_MAP_CENTER, + SWEDEN_MAP_ZOOM, +} from "../lib/marinas"; +import { cn } from "../lib/utils"; + +function MapFitBounds() { + const map = useMap(); + + useEffect(() => { + if (MARINA_LIST.length < 2) return; + const bounds = L.latLngBounds(MARINA_LIST.map((m) => m.position)); + map.fitBounds(bounds, { padding: [48, 48], maxZoom: 5 }); + }, [map]); + + return null; +} + +function MarinaTooltipContent({ + marina, + isHovered, +}: { + marina: MarinaInfo; + isHovered: boolean; +}) { + const status = getMarinaMapStatus(marina); + + return ( +
+
+ + + +
+

{marina.name}

+ {marina.region && ( +

{marina.region}

+ )} +
+
+ {marina.tagline && ( +

{marina.tagline}

+ )} +

+ {status === "not-planned" + ? "Not planned" + : status === "coming-soon" + ? "Coming soon" + : isHovered + ? "Click to open harbor" + : "Hover for details"} +

+
+ ); +} + +function MarinaMarker({ + marina, + isHovered, + onHover, +}: { + marina: MarinaInfo; + isHovered: boolean; + onHover: (slug: string | null) => void; +}) { + const navigate = useNavigate(); + const status = getMarinaMapStatus(marina); + + const eventHandlers = useMemo( + () => ({ + mouseover: () => onHover(marina.slug), + mouseout: () => onHover(null), + click: () => { + if (status === "not-planned") { + toast.error(`${marina.name} · not planned`, { + description: "No rollout planned for this location.", + }); + return; + } + if (status === "coming-soon") { + toast.info(`${marina.name} is not on DockPulse yet`, { + description: + "This marina is on the roadmap. Saltsjöbaden is live today.", + }); + return; + } + navigate(`/${marina.slug}`); + }, + }), + [marina.name, marina.slug, navigate, onHover, status], + ); + + const pathOptions = useMemo(() => { + if (status === "not-planned") { + return { + color: "#7f1d1d", + weight: isHovered ? 2 : 1.5, + fillColor: isHovered ? "#ef4444" : "#dc2626", + fillOpacity: isHovered ? 1 : 0.88, + }; + } + if (status === "coming-soon") { + return { + color: "#0a2540", + weight: isHovered ? 2 : 1.5, + fillColor: isHovered ? "#94a3b8" : "#cbd5e1", + fillOpacity: isHovered ? 0.9 : 0.7, + }; + } + return { + color: "#0a2540", + weight: isHovered ? 2 : 1.5, + fillColor: isHovered ? "#00e5ff" : "#0093e9", + fillOpacity: isHovered ? 1 : 0.85, + }; + }, [isHovered, status]); + + const radius = status === "live" ? (isHovered ? 7 : 6) : isHovered ? 7 : 5; + + return ( + + + + + + ); +} + +export function SwedenMarinaMap() { + const [hoveredSlug, setHoveredSlug] = useState(null); + + return ( + + + + {MARINA_LIST.map((marina) => ( + + ))} + +
+ + Hover a marina · click to open +
+
+ ); +} diff --git a/frontend/src/components/layout/DashboardLayoutContext.tsx b/frontend/src/components/layout/DashboardLayoutContext.tsx index 58f6a92..0255fb0 100644 --- a/frontend/src/components/layout/DashboardLayoutContext.tsx +++ b/frontend/src/components/layout/DashboardLayoutContext.tsx @@ -148,6 +148,7 @@ export function DashboardLayoutProvider({ }} >
(null); const desktopMenuRef = useRef(null); - const isOnMarinaHome = location.pathname === marinaPath; + const isOnSwedenMap = location.pathname === "/"; useEffect(() => { function handleOutsidePointer(event: MouseEvent) { @@ -152,15 +155,17 @@ function Header({ )} - - Dashboard - + {!isMarinaPicker && ( + + Dashboard + + )} - {isLoggedIn && !isHarbormaster && ( + {isLoggedIn && !isHarbormaster && marinaSlug && ( )} - {isLoggedIn && ( + {isLoggedIn && marinaSlug && ( <> - {isOnMarinaHome ? ( + {isOnSwedenMap ? (
{logoContent}
) : ( - + {logoContent} )} @@ -230,7 +240,7 @@ function Header({ {isDesktopMenuOpen && (
- {!isHarbormaster && ( + {!isHarbormaster && marinaSlug && ( )} - - Settings - + {marinaSlug && ( + + Settings + + )}