Add interactive school map to homepage
Some checks failed
Build and Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Tim Hadwen
2026-03-06 22:37:37 +10:00
parent 2dfdefbdf4
commit bb2a56e7c1
10 changed files with 1501 additions and 23 deletions

52
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

661
public/leaflet.css Normal file
View File

@@ -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;
}
}

164
scripts/fetch-schools.ts Normal file
View File

@@ -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<AirtableRecord[]> {
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);
});

View File

@@ -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);

View File

@@ -31,6 +31,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<head>
<link rel="stylesheet" href="/leaflet.css" />
</head>
<body className={`${inter.variable} antialiased`}>
<Header />
<main className="min-h-screen pt-16">{children}</main>

View File

@@ -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() {
</section>
{/* Schools */}
<section className="bg-muted py-16">
<Container>
<SectionHeading
title="Over 120 Schools Using Micromelon"
subtitle="From primary schools to universities across Australia."
/>
<div className="mx-auto max-w-4xl columns-2 gap-x-8 sm:columns-3 md:columns-4">
{KNOWN_SCHOOLS.slice(0, 50).map((school) => (
<p
key={school}
className="mb-1.5 break-inside-avoid text-sm text-foreground-light"
>
{school}
</p>
))}
</div>
<p className="mt-6 text-center text-sm font-medium text-foreground-light">
and many more...
</p>
</Container>
</section>
<SchoolMapLoader />
{/* Partners */}
<section className="bg-white py-16">

View File

@@ -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 (
<div className="relative" style={{ height: 560, width: "100%" }}>
<MapContainer
center={[-25.5, 134]}
zoom={4}
zoomControl={false}
attributionControl={false}
style={{ height: "100%", width: "100%" }}
>
{/* Base map without labels */}
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>'
/>
{/* 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) => (
<CircleMarker
key={`glow-${ring.radius}-${i}`}
center={[pin.lat, pin.lng]}
radius={ring.radius}
pathOptions={{
color: "transparent",
fillColor: "#F5A623",
fillOpacity: ring.opacity,
weight: 0,
}}
/>
))
)}
{/* Solid dot */}
{schoolPins.map((pin, i) => (
<CircleMarker
key={i}
center={[pin.lat, pin.lng]}
radius={3}
pathOptions={{
color: "#d4900e",
fillColor: "#F5A623",
fillOpacity: 0.9,
weight: 1.5,
}}
/>
))}
<MapSetup />
</MapContainer>
<div className="pointer-events-none absolute inset-0 z-[1000] flex items-start justify-center pt-8 sm:pt-12">
<div className="text-center">
<h2 className="text-4xl font-bold tracking-tight text-foreground drop-shadow-[0_1px_2px_rgba(255,255,255,0.8)] sm:text-5xl">
Schools Using Micromelon
</h2>
<p className="mt-2 text-lg font-semibold text-foreground-light drop-shadow-[0_1px_2px_rgba(255,255,255,0.8)] sm:text-xl">
120+ schools across Australia
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import dynamic from "next/dynamic";
const SchoolMap = dynamic(() => import("@/components/SchoolMap"), {
ssr: false,
loading: () => (
<div className="h-[400px] w-full animate-pulse bg-foreground/5 sm:h-[480px]" />
),
});
export default function SchoolMapLoader() {
return <SchoolMap />;
}

442
src/data/schools.json Normal file
View File

@@ -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
}
]