From bb2a56e7c13dc87ba098170093ff7fe44e2fddf0 Mon Sep 17 00:00:00 2001 From: Tim Hadwen Date: Fri, 6 Mar 2026 22:37:37 +1000 Subject: [PATCH] Add interactive school map to homepage Replace the text list of schools with a Leaflet map showing anonymous orange pins for 110 customer schools across Australia. Includes build-time Airtable fetch script with Nominatim geocoding and gradient glow effect on clustered pins. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 52 +++ package.json | 6 +- public/leaflet.css | 661 +++++++++++++++++++++++++++++ scripts/fetch-schools.ts | 164 +++++++ scripts/seed-schools-json.mjs | 63 +++ src/app/layout.tsx | 3 + src/app/page.tsx | 24 +- src/components/SchoolMap.tsx | 95 +++++ src/components/SchoolMapLoader.tsx | 14 + src/data/schools.json | 442 +++++++++++++++++++ 10 files changed, 1501 insertions(+), 23 deletions(-) create mode 100644 public/leaflet.css create mode 100644 scripts/fetch-schools.ts create mode 100644 scripts/seed-schools-json.mjs create mode 100644 src/components/SchoolMap.tsx create mode 100644 src/components/SchoolMapLoader.tsx create mode 100644 src/data/schools.json diff --git a/package-lock.json b/package-lock.json index e3ea773..7b3400d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,17 @@ "version": "0.1.0", "dependencies": { "gray-matter": "^4.0.3", + "leaflet": "^1.9.4", "next": "16.1.6", "next-mdx-remote": "^6.0.0", "react": "19.2.3", "react-dom": "19.2.3", + "react-leaflet": "^5.0.0", "stripe": "^20.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1282,6 +1285,17 @@ "node": ">=12.4.0" } }, + "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/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1604,6 +1618,13 @@ "@types/estree": "*" } }, + "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/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1627,6 +1648,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/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5126,6 +5157,13 @@ "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", + "peer": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6885,6 +6923,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/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", diff --git a/package.json b/package.json index 663224c..929cd6b 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,22 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "fetch-schools": "npx tsx scripts/fetch-schools.ts" }, "dependencies": { "gray-matter": "^4.0.3", + "leaflet": "^1.9.4", "next": "16.1.6", "next-mdx-remote": "^6.0.0", "react": "19.2.3", "react-dom": "19.2.3", + "react-leaflet": "^5.0.0", "stripe": "^20.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/leaflet.css b/public/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/public/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/scripts/fetch-schools.ts b/scripts/fetch-schools.ts new file mode 100644 index 0000000..b3c806e --- /dev/null +++ b/scripts/fetch-schools.ts @@ -0,0 +1,164 @@ +/** + * Fetches active AU school organisations from Airtable, + * geocodes their addresses via Nominatim, and writes + * an anonymous lat/lng JSON file for the homepage map. + * + * Usage: + * AIRTABLE_API_KEY=pat... AIRTABLE_BASE_ID=app... npx tsx scripts/fetch-schools.ts + */ + +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; + +const AIRTABLE_API_KEY = process.env.AIRTABLE_API_KEY; +const AIRTABLE_BASE_ID = process.env.AIRTABLE_BASE_ID; +const TABLE_NAME = "Organisation"; + +if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_ID) { + console.error("Missing AIRTABLE_API_KEY or AIRTABLE_BASE_ID env vars"); + process.exit(1); +} + +interface AirtableRecord { + id: string; + fields: { + "Google Formatted Address"?: string; + State?: string; + }; +} + +interface AirtableResponse { + records: AirtableRecord[]; + offset?: string; +} + +interface SchoolPin { + lat: number; + lng: number; +} + +async function fetchAllRecords(): Promise { + const allRecords: AirtableRecord[] = []; + let offset: string | undefined; + + const filterFormula = "{Organisation Type}='Customer'"; + const fields = ["Google Formatted Address", "State"]; + + do { + const params = new URLSearchParams({ + filterByFormula: filterFormula, + }); + fields.forEach((f) => params.append("fields[]", f)); + if (offset) params.set("offset", offset); + + const url = `https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(TABLE_NAME)}?${params}`; + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${AIRTABLE_API_KEY}` }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Airtable API error ${res.status}: ${body}`); + } + + const data: AirtableResponse = await res.json(); + allRecords.push(...data.records); + offset = data.offset; + } while (offset); + + return allRecords; +} + +function simplifyAddress(address: string): string[] { + const variants: string[] = []; + + // Strip unit/lot prefixes like "4506/793" → "793" + let simplified = address.replace(/\d+\/(\d+)/, "$1"); + // Replace "&" intersections with just the first street + simplified = simplified.replace(/\s*&\s*[^,]+/, ""); + if (simplified !== address) variants.push(simplified); + + // Try just suburb + state + postcode (everything after the first comma) + const afterFirstComma = address.replace(/^[^,]+,\s*/, ""); + if (afterFirstComma !== address) variants.push(afterFirstComma); + + return variants; +} + +async function geocodeSingle(query: string): Promise<{ lat: number; lng: number } | null> { + const params = new URLSearchParams({ + q: query, + format: "json", + limit: "1", + countrycodes: "au", + }); + + const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { "User-Agent": "micromelon-website-build/1.0" }, + }); + + if (!res.ok) return null; + + const data = await res.json(); + if (!data.length) return null; + + return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; +} + +async function geocode(address: string): Promise<{ lat: number; lng: number } | null> { + // Try the full address first + const result = await geocodeSingle(address); + if (result) return result; + + // Try simplified variants + for (const variant of simplifyAddress(address)) { + await new Promise((r) => setTimeout(r, 1100)); + const fallback = await geocodeSingle(variant); + if (fallback) return fallback; + } + + return null; +} + +async function main() { + console.log("Fetching records from Airtable..."); + const records = await fetchAllRecords(); + console.log(`Found ${records.length} active AU school records`); + + const pins: SchoolPin[] = []; + let failed = 0; + + for (const record of records) { + const address = record.fields["Google Formatted Address"]; + if (!address) { + failed++; + continue; + } + + // Rate-limit: Nominatim asks for max 1 req/sec + await new Promise((r) => setTimeout(r, 1100)); + + const coords = await geocode(address); + if (coords) { + pins.push(coords); + process.stdout.write(` Geocoded ${pins.length}/${records.length}\r`); + } else { + console.warn(` Failed to geocode: ${address}`); + failed++; + } + } + + console.log(`\nGeocoded ${pins.length} schools (${failed} failed)`); + + const outDir = join(__dirname, "..", "src", "data"); + mkdirSync(outDir, { recursive: true }); + const outPath = join(outDir, "schools.json"); + writeFileSync(outPath, JSON.stringify(pins, null, 2) + "\n"); + console.log(`Wrote ${outPath}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-schools-json.mjs b/scripts/seed-schools-json.mjs new file mode 100644 index 0000000..27211d1 --- /dev/null +++ b/scripts/seed-schools-json.mjs @@ -0,0 +1,63 @@ +/** + * One-time script: geocodes the existing KNOWN_SCHOOLS list to create + * an initial src/data/schools.json. Run once, then use fetch-schools.ts + * with Airtable for ongoing updates. + */ + +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Parse the school names from schools.ts +const schoolsTs = readFileSync(join(__dirname, "..", "src", "data", "schools.ts"), "utf-8"); +const match = schoolsTs.match(/\[([^\]]+)\]/s); +const schools = match + ? match[1] + .split(",") + .map((s) => s.trim().replace(/^"|"$/g, "").replace(/^'|'$/g, "")) + .filter(Boolean) + : []; + +console.log(`Found ${schools.length} schools to geocode`); + +async function geocode(query) { + const params = new URLSearchParams({ + q: query + ", Australia", + format: "json", + limit: "1", + countrycodes: "au", + }); + + const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { "User-Agent": "micromelon-website-seed/1.0" }, + }); + + if (!res.ok) return null; + const data = await res.json(); + if (!data.length) return null; + return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }; +} + +async function main() { + const pins = []; + for (let i = 0; i < schools.length; i++) { + await new Promise((r) => setTimeout(r, 1100)); + const coords = await geocode(schools[i]); + if (coords) { + pins.push(coords); + process.stdout.write(` ${i + 1}/${schools.length} — ${pins.length} geocoded\r`); + } else { + console.log(` MISS: ${schools[i]}`); + } + } + + console.log(`\nDone: ${pins.length} geocoded out of ${schools.length}`); + const outDir = join(__dirname, "..", "src", "data"); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "schools.json"), JSON.stringify(pins, null, 2) + "\n"); + console.log("Wrote src/data/schools.json"); +} + +main().catch(console.error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0e9b22e..83839df 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -31,6 +31,9 @@ export default function RootLayout({ }>) { return ( + + +
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index ae0677e..099e9f2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/Button"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { LearningPathway } from "@/components/ui/LearningPathway"; import { partners } from "@/data/partners"; -import { KNOWN_SCHOOLS } from "@/data/schools"; +import SchoolMapLoader from "@/components/SchoolMapLoader"; export const metadata: Metadata = { title: "Micromelon Robotics", @@ -152,27 +152,7 @@ export default function HomePage() { {/* Schools */} -
- - -
- {KNOWN_SCHOOLS.slice(0, 50).map((school) => ( -

- {school} -

- ))} -
-

- and many more... -

-
-
+ {/* Partners */}
diff --git a/src/components/SchoolMap.tsx b/src/components/SchoolMap.tsx new file mode 100644 index 0000000..e0635ea --- /dev/null +++ b/src/components/SchoolMap.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect } from "react"; +import { MapContainer, TileLayer, CircleMarker, useMap } from "react-leaflet"; +import schoolPins from "@/data/schools.json"; + + + +function MapSetup() { + const map = useMap(); + + useEffect(() => { + // Leaflet needs a kick after dynamic import to know its real size + setTimeout(() => map.invalidateSize(), 0); + + // Center on Australia + map.setView([-28, 134], 3.5); + + // Disable all interaction — static display only + map.dragging.disable(); + map.touchZoom.disable(); + map.doubleClickZoom.disable(); + map.scrollWheelZoom.disable(); + map.boxZoom.disable(); + map.keyboard.disable(); + if ((map as any).tap) (map as any).tap.disable(); + }, [map]); + + return null; +} + +export default function SchoolMap() { + return ( +
+ + {/* Base map without labels */} + + {/* Gradient glow — concentric rings, dimmer further out */} + {[ + { radius: 22, opacity: 0.04 }, + { radius: 16, opacity: 0.08 }, + { radius: 10, opacity: 0.15 }, + ].map((ring) => + schoolPins.map((pin, i) => ( + + )) + )} + {/* Solid dot */} + {schoolPins.map((pin, i) => ( + + ))} + + +
+
+

+ Schools Using Micromelon +

+

+ 120+ schools across Australia +

+
+
+
+ ); +} diff --git a/src/components/SchoolMapLoader.tsx b/src/components/SchoolMapLoader.tsx new file mode 100644 index 0000000..47cccc8 --- /dev/null +++ b/src/components/SchoolMapLoader.tsx @@ -0,0 +1,14 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const SchoolMap = dynamic(() => import("@/components/SchoolMap"), { + ssr: false, + loading: () => ( +
+ ), +}); + +export default function SchoolMapLoader() { + return ; +} diff --git a/src/data/schools.json b/src/data/schools.json new file mode 100644 index 0000000..ac854a7 --- /dev/null +++ b/src/data/schools.json @@ -0,0 +1,442 @@ +[ + { + "lat": -37.7470993, + "lng": 144.9990095 + }, + { + "lat": -27.5240086, + "lng": 153.2137627 + }, + { + "lat": -21.120363, + "lng": 144.2152618 + }, + { + "lat": -37.6560398, + "lng": 145.0326854 + }, + { + "lat": -23.3698363, + "lng": 150.5067508 + }, + { + "lat": -21.1125705, + "lng": 149.1567627 + }, + { + "lat": -28.092001, + "lng": 153.3922967 + }, + { + "lat": -26.4241189, + "lng": 152.9093994 + }, + { + "lat": -38.0355233, + "lng": 145.2592145 + }, + { + "lat": -32.7720651, + "lng": 151.5996816 + }, + { + "lat": -35.2882497, + "lng": 149.1394495 + }, + { + "lat": -27.624897, + "lng": 152.9707622 + }, + { + "lat": -27.5557271, + "lng": 151.9440799 + }, + { + "lat": -27.6734214, + "lng": 153.1422588 + }, + { + "lat": -33.9483376, + "lng": 150.8552614 + }, + { + "lat": -26.774088, + "lng": 153.1036408 + }, + { + "lat": -36.1288805, + "lng": 146.861592 + }, + { + "lat": -27.8688383, + "lng": 153.2983642 + }, + { + "lat": -27.3118477, + "lng": 153.0029511 + }, + { + "lat": -27.2976778, + "lng": 152.9650592 + }, + { + "lat": -37.8767547, + "lng": 145.1368525 + }, + { + "lat": -27.7072284, + "lng": 153.1483804 + }, + { + "lat": -27.4714517, + "lng": 153.1511855 + }, + { + "lat": -27.4152112, + "lng": 152.9893319 + }, + { + "lat": -27.4575131, + "lng": 153.0252896 + }, + { + "lat": -27.517615, + "lng": 152.9436013 + }, + { + "lat": -26.6232019, + "lng": 152.9622034 + }, + { + "lat": -27.7102598, + "lng": 153.1982203 + }, + { + "lat": -33.8738021, + "lng": 151.205592 + }, + { + "lat": -21.1399601, + "lng": 149.1847447 + }, + { + "lat": -37.7229788, + "lng": 144.9901913 + }, + { + "lat": -27.6387676, + "lng": 153.1294174 + }, + { + "lat": -27.3238094, + "lng": 153.0825981 + }, + { + "lat": -36.030406, + "lng": 146.9843354 + }, + { + "lat": -12.6269041, + "lng": 141.8802825 + }, + { + "lat": -27.5555213, + "lng": 153.2531729 + }, + { + "lat": -23.1178776, + "lng": 150.7278551 + }, + { + "lat": -21.1166674, + "lng": 149.182293 + }, + { + "lat": -25.2326385, + "lng": 152.2739552 + }, + { + "lat": -37.7010073, + "lng": 144.8054491 + }, + { + "lat": -27.2886947, + "lng": 152.9536783 + }, + { + "lat": -33.724656, + "lng": 151.120942 + }, + { + "lat": -41.6870276, + "lng": 147.0800587 + }, + { + "lat": -32.0528106, + "lng": 150.8673349 + }, + { + "lat": -16.9012244, + "lng": 145.7564813 + }, + { + "lat": -22.0058932, + "lng": 148.0403261 + }, + { + "lat": -27.2398578, + "lng": 153.0254814 + }, + { + "lat": -27.660526, + "lng": 153.117401 + }, + { + "lat": -23.3884898, + "lng": 150.5010833 + }, + { + "lat": -21.101477, + "lng": 149.1733104 + }, + { + "lat": -37.7574733, + "lng": 144.914184 + }, + { + "lat": -33.8626452, + "lng": 151.2712213 + }, + { + "lat": -24.4017852, + "lng": 150.5133571 + }, + { + "lat": -27.1086456, + "lng": 152.9525619 + }, + { + "lat": -19.2778499, + "lng": 146.7990098 + }, + { + "lat": -28.2330978, + "lng": 153.5062211 + }, + { + "lat": -41.3244809, + "lng": 148.2462093 + }, + { + "lat": -28.2249897, + "lng": 152.0266401 + }, + { + "lat": -34.1090269, + "lng": 150.7481889 + }, + { + "lat": -27.584498, + "lng": 153.1228658 + }, + { + "lat": -36.3907542, + "lng": 148.595996 + }, + { + "lat": -31.1292591, + "lng": 150.9413209 + }, + { + "lat": -27.4582814, + "lng": 152.9804184 + }, + { + "lat": -33.7325337, + "lng": 151.3012869 + }, + { + "lat": -33.882939, + "lng": 151.1151587 + }, + { + "lat": -37.9110974, + "lng": 145.0386427 + }, + { + "lat": -26.7096197, + "lng": 153.0565813 + }, + { + "lat": -36.7616888, + "lng": 144.271027 + }, + { + "lat": -28.0874637, + "lng": 153.379925 + }, + { + "lat": -27.3594608, + "lng": 153.0640429 + }, + { + "lat": -37.8652775, + "lng": 145.1305158 + }, + { + "lat": -27.5050162, + "lng": 152.9845071 + }, + { + "lat": -27.4850834, + "lng": 153.0531124 + }, + { + "lat": -27.4601989, + "lng": 153.0184366 + }, + { + "lat": -33.7462292, + "lng": 151.134731 + }, + { + "lat": -27.4526498, + "lng": 153.0639908 + }, + { + "lat": -27.6133559, + "lng": 152.9730796 + }, + { + "lat": -28.8622657, + "lng": 153.5302213 + }, + { + "lat": -33.741528, + "lng": 151.059437 + }, + { + "lat": -27.4681298, + "lng": 152.9729962 + }, + { + "lat": -27.2427895, + "lng": 153.0433373 + }, + { + "lat": -26.6870455, + "lng": 153.1016224 + }, + { + "lat": -28.0000721, + "lng": 153.3277205 + }, + { + "lat": -25.297344, + "lng": 152.8154248 + }, + { + "lat": -27.4869062, + "lng": 152.9878806 + }, + { + "lat": -27.5414255, + "lng": 151.9539225 + }, + { + "lat": -33.9006998, + "lng": 151.1320518 + }, + { + "lat": -37.8540303, + "lng": 145.1193094 + }, + { + "lat": -26.741302, + "lng": 153.1266712 + }, + { + "lat": -26.5359734, + "lng": 151.8403736 + }, + { + "lat": -27.4609906, + "lng": 153.0338245 + }, + { + "lat": -27.5344463, + "lng": 153.1067123 + }, + { + "lat": -27.4383857, + "lng": 152.9853557 + }, + { + "lat": -34.8937504, + "lng": 138.5944799 + }, + { + "lat": -26.1865427, + "lng": 152.6796877 + }, + { + "lat": -27.5270447, + "lng": 153.2146544 + }, + { + "lat": -26.543087, + "lng": 151.8338523 + }, + { + "lat": -27.618754, + "lng": 153.0920355 + }, + { + "lat": -23.1306451, + "lng": 150.7436057 + }, + { + "lat": -27.4843986, + "lng": 153.0504723 + }, + { + "lat": -27.4248283, + "lng": 153.0562791 + }, + { + "lat": -27.5459026, + "lng": 152.9875492 + }, + { + "lat": -17.004629, + "lng": 145.7416381 + }, + { + "lat": -27.4484886, + "lng": 153.0140922 + }, + { + "lat": -32.0062232, + "lng": 116.038894 + }, + { + "lat": -27.4945588, + "lng": 153.0279224 + }, + { + "lat": -23.1350531, + "lng": 150.7370891 + }, + { + "lat": -27.5061536, + "lng": 152.9678737 + }, + { + "lat": -27.5900938, + "lng": 152.7445426 + }, + { + "lat": -35.247, + "lng": 149.083 + } +]