Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copy to .env.local for dev, or set on Vercel for prod.
# Base URL of the Laravel API (no trailing slash, no /api suffix).
VITE_API_BASE=http://localhost:8000

# CARTO basemap API key. CARTO enforces keys on basemaps.cartocdn.com since
# Aug 2026 (anonymous tiles are watermarked "API KEY REQUIRED"). Free, no
# account: https://carto.com/basemaps/apikey/ . Leave unset to fall back to
# keyless OpenStreetMap tiles (light style; dark mode inverts them via CSS).
VITE_CARTO_API_KEY=
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ CI runs both on every push to `main`.

Targeted at Vercel — auto-detects Vite, uses the `npm run build` command, serves `dist/`. Set `VITE_API_BASE` to the production Laravel URL in Vercel project settings.

Also set `VITE_CARTO_API_KEY` (free key from <https://carto.com/basemaps/apikey/>) so the map uses CARTO's theme-matched light/dark basemaps. Without it the map falls back to OpenStreetMap's keyless tiles (dark mode inverts them via CSS) — see `src/components/mapTiles.js`.

## License

MIT
21 changes: 15 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -13,11 +13,15 @@
<link rel="dns-prefetch" href="https://a.basemaps.cartocdn.com" />
<link rel="dns-prefetch" href="https://b.basemaps.cartocdn.com" />
<link rel="dns-prefetch" href="https://c.basemaps.cartocdn.com" />
<link rel="dns-prefetch" href="https://tile.openstreetmap.org" />

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Roastmap — the map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock." />
<meta
name="description"
content="Roastmap — the map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock."
/>
<title>Roastmap — Canadian specialty coffee</title>

<!-- Canonical + social cards. These are the no-JS fallbacks that crawlers
Expand All @@ -29,11 +33,17 @@
<meta property="og:site_name" content="Roastmap" />
<meta property="og:locale" content="en_CA" />
<meta property="og:title" content="Roastmap — Canadian specialty coffee" />
<meta property="og:description" content="The map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock." />
<meta
property="og:description"
content="The map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock."
/>
<meta property="og:url" content="https://roastmap.ca/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Roastmap — Canadian specialty coffee" />
<meta name="twitter:description" content="The map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock." />
<meta
name="twitter:description"
content="The map of Canadian specialty coffee. Find micro-roasters near you, browse current beans, compare prices and stock."
/>

<!-- Structured data: a WebSite node with a SearchAction so search engines
can offer a sitelinks search box that deep-links into the bean search. -->
Expand Down Expand Up @@ -63,8 +73,7 @@
(function () {
try {
var s = localStorage.getItem('roastmap_theme');
var dark = s ? s === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = s ? s === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.style.backgroundColor = dark ? '#100c0a' : '#ece5da';
if (dark) {
document.documentElement.classList.add('dark');
Expand Down
32 changes: 17 additions & 15 deletions src/components/LeafletMap.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,7 @@ import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import '../styles/leaflet-overrides.css'; // mobile#6: must load AFTER leaflet.css
import ClusterLayer from './ClusterLayer.jsx';

// Theme-matched CARTO basemaps. Everything else on the map (popups, markers,
// clusters) themes itself via CSS tokens in leaflet-overrides.css; the raster
// tiles are the one piece that needs an explicit URL swap.
const TILE_URLS = {
light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
};
const TILE_ATTR =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
import { resolveTileLayer } from './mapTiles.js';

/**
* Imperatively re-fits the map when the target bounds change. Must live inside
Expand Down Expand Up @@ -53,7 +44,13 @@ function FocusOnReady({ enabled }) {
* first interaction, so the landing page paints instantly and this vendor
* weight stays off the critical path entirely.
*/
export default function LeafletMap({ markers, targetBounds, isDark = false, focusOnMount = false }) {
export default function LeafletMap({
markers,
targetBounds,
isDark = false,
focusOnMount = false,
}) {
const tiles = resolveTileLayer({ isDark });
return (
<MapContainer
center={[56, -106]}
Expand All @@ -63,11 +60,16 @@ export default function LeafletMap({ markers, targetBounds, isDark = false, focu
>
{/* key-remount on theme change: react-leaflet does NOT propagate a
mutated `url` prop to a live tile layer, so swapping themes in
place silently no-ops. A fresh layer per theme is cheap and safe. */}
place silently no-ops. A fresh layer per theme is cheap and safe.
Provider + URL come from mapTiles.js (CARTO with an API key, else
keyless OSM) — see that file for why. */}
<TileLayer
key={isDark ? 'dark' : 'light'}
url={isDark ? TILE_URLS.dark : TILE_URLS.light}
attribution={TILE_ATTR}
key={`${tiles.provider}-${isDark ? 'dark' : 'light'}`}
url={tiles.url}
attribution={tiles.attribution}
subdomains={tiles.subdomains}
maxZoom={tiles.maxZoom}
className={tiles.className}
/>
<MapBoundsController targetBounds={targetBounds} />
<FocusOnReady enabled={focusOnMount} />
Expand Down
70 changes: 70 additions & 0 deletions src/components/mapTiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Basemap tile-layer selection for LeafletMap.
*
* CARTO started enforcing API keys on basemaps.cartocdn.com in late August
* 2026: anonymous tile requests still render but carry a repeated
* "API KEY REQUIRED" watermark. A key is free (5M tiles/month, requested by
* email at https://carto.com/basemaps/apikey/) and is passed as `?key=` on
* the same tile URL.
*
* With VITE_CARTO_API_KEY set (Vercel env for prod, .env.local for dev) we
* keep the theme-matched CARTO light/dark styles. Without it we fall back to
* the OpenStreetMap standard tiles — keyless, but light-only, so dark mode
* inverts them via the `map-tiles--inverted` CSS class in
* leaflet-overrides.css. OSM's tile usage policy is fine for this site's
* traffic but asks for attribution and a real Referer, both of which a
* browser request provides.
*
* Pure (no Leaflet import) so it stays unit-testable and out of the lazy map
* chunk's critical path.
*/

const CARTO = {
light: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
dark: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
subdomains: 'abcd',
maxZoom: 20,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
};

// OSM retired the a/b/c subdomains; the bare host is the supported endpoint.
const OSM = {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
};

export const INVERTED_TILES_CLASS = 'map-tiles--inverted';

export function readCartoKey(env = import.meta.env) {
const key = env?.VITE_CARTO_API_KEY;
return typeof key === 'string' && key.trim() !== '' ? key.trim() : null;
}

/**
* @param {{ isDark: boolean, cartoKey?: string | null }} opts
* @returns {{ provider: 'carto' | 'osm', url: string, attribution: string,
* subdomains?: string, maxZoom: number, className?: string }}
*/
export function resolveTileLayer({ isDark, cartoKey = readCartoKey() }) {
if (cartoKey) {
const base = isDark ? CARTO.dark : CARTO.light;
return {
provider: 'carto',
url: `${base}?key=${encodeURIComponent(cartoKey)}`,
attribution: CARTO.attribution,
subdomains: CARTO.subdomains,
maxZoom: CARTO.maxZoom,
};
}

return {
provider: 'osm',
url: OSM.url,
attribution: OSM.attribution,
maxZoom: OSM.maxZoom,
className: isDark ? INVERTED_TILES_CLASS : undefined,
};
}
51 changes: 51 additions & 0 deletions src/components/mapTiles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { resolveTileLayer, readCartoKey, INVERTED_TILES_CLASS } from './mapTiles.js';

describe('resolveTileLayer', () => {
it('uses CARTO light/dark with the key on the query string when a key is configured', () => {
const light = resolveTileLayer({ isDark: false, cartoKey: 'abc123' });
const dark = resolveTileLayer({ isDark: true, cartoKey: 'abc123' });

expect(light.provider).toBe('carto');
expect(light.url).toBe(
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=abc123',
);
expect(dark.url).toBe(
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png?key=abc123',
);
expect(light.subdomains).toBe('abcd');
expect(light.attribution).toContain('carto.com/attributions');
expect(light.className).toBeUndefined();
expect(dark.className).toBeUndefined();
});

it('URL-encodes the key', () => {
expect(resolveTileLayer({ isDark: false, cartoKey: 'a b&c' }).url).toMatch(/\?key=a%20b%26c$/);
});

it('falls back to keyless OpenStreetMap tiles without a key', () => {
const light = resolveTileLayer({ isDark: false, cartoKey: null });

expect(light.provider).toBe('osm');
expect(light.url).toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png');
expect(light.url).not.toContain('cartocdn');
expect(light.attribution).toContain('openstreetmap.org/copyright');
expect(light.attribution).not.toContain('CARTO');
expect(light.subdomains).toBeUndefined();
expect(light.className).toBeUndefined();
});

it('inverts the OSM fallback in dark mode via a CSS class', () => {
expect(resolveTileLayer({ isDark: true, cartoKey: null }).className).toBe(INVERTED_TILES_CLASS);
});
});

describe('readCartoKey', () => {
it('returns the trimmed key or null', () => {
expect(readCartoKey({ VITE_CARTO_API_KEY: ' k1 ' })).toBe('k1');
expect(readCartoKey({ VITE_CARTO_API_KEY: '' })).toBeNull();
expect(readCartoKey({ VITE_CARTO_API_KEY: ' ' })).toBeNull();
expect(readCartoKey({})).toBeNull();
expect(readCartoKey(undefined)).toBeNull();
});
});
33 changes: 28 additions & 5 deletions src/styles/leaflet-overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,16 @@ a.leaflet-popup-close-button:hover {
inset: -6px;
border-radius: 50%;
border: 2px solid #fef6e7;
box-shadow: 0 0 0 2px #6f4326, 0 4px 8px rgba(0, 0, 0, 0.4);
box-shadow:
0 0 0 2px #6f4326,
0 4px 8px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.dark .rm-bean__ring {
border-color: #15100d;
box-shadow: 0 0 0 2px #e7d3b8, 0 4px 8px rgba(0, 0, 0, 0.7);
box-shadow:
0 0 0 2px #e7d3b8,
0 4px 8px rgba(0, 0, 0, 0.7);
}

/* Cluster bubble. Size/font/halo come inline from createClusterIcon
Expand All @@ -202,8 +206,13 @@ a.leaflet-popup-close-button:hover {
border: 2px solid #fef6e7;
border-radius: 50%;
font-weight: 700;
font-family: Inter, system-ui, -apple-system, sans-serif;
box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25),
font-family:
Inter,
system-ui,
-apple-system,
sans-serif;
box-shadow:
0 0 0 var(--rm-cluster-halo, 4px) rgba(111, 67, 38, 0.25),
0 2px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: transform 120ms ease-out;
Expand All @@ -212,7 +221,8 @@ a.leaflet-popup-close-button:hover {
background: #8a5a35;
color: #fef6e7;
border-color: #15100d;
box-shadow: 0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2),
box-shadow:
0 0 0 var(--rm-cluster-halo, 4px) rgba(231, 211, 184, 0.2),
0 2px 4px rgba(0, 0, 0, 0.6);
}

Expand All @@ -237,3 +247,16 @@ a.leaflet-popup-close-button:hover {
.dark .leaflet-container {
background: #0e0d0c;
}

/*
* ---- Keyless basemap fallback (dark mode) ----
*
* Without VITE_CARTO_API_KEY the map serves OpenStreetMap's standard tiles,
* which only come in a light style. mapTiles.js tags that layer with this
* class in dark mode so the raster inverts to a dark canvas; hue-rotate
* restores land/water colours after the inversion. Scoped to the tile
* container only — markers, clusters and popups are separate panes.
*/
.leaflet-layer.map-tiles--inverted {
filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.95) saturate(0.55);
}
Loading