217 lines
12 KiB
TypeScript
217 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import Image from "next/image";
|
||
import type { Map as LeafletMap, Marker, TileLayer } from "leaflet";
|
||
import {
|
||
ChevronLeft, Coffee, Heart, Layers, LocateFixed, MapPin,
|
||
Minus, PanelLeft, Plus, Search, X,
|
||
} from "lucide-react";
|
||
import { COFFEE_SHOPS, type CoffeeShop } from "~/lib/coffee-shops";
|
||
import "leaflet/dist/leaflet.css";
|
||
|
||
type MapStyle = "street" | "satellite";
|
||
|
||
const images = [
|
||
"amami-kitchen", "culture-coffee", "7th-st-cafe", "tastecraft-cafe",
|
||
"paris-bakery-caf", "cycleup-coffee", "cornerstone-kitchen", "gram-s-eatery",
|
||
"starbucks-coffee", "barnes-noble-caf", "starbucks-giant", "dunkin",
|
||
"alee-s-cafe", "all-star-bagels", "panera-bread", "street-of-shops",
|
||
];
|
||
|
||
function shopImage(shop: CoffeeShop) {
|
||
return `/coffee/shops/${images[shop.id - 1]}.jpg`;
|
||
}
|
||
|
||
const tiles: Record<MapStyle, { url: string; attribution: string }> = {
|
||
street: {
|
||
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
},
|
||
satellite: {
|
||
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||
attribution: "Tiles © Esri",
|
||
},
|
||
};
|
||
|
||
export function CoffeeMirror() {
|
||
const [selected, setSelected] = useState<CoffeeShop | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(true);
|
||
const [query, setQuery] = useState("");
|
||
const [favorites, setFavorites] = useState<number[]>([]);
|
||
const [style, setStyle] = useState<MapStyle>("street");
|
||
const [styleOpen, setStyleOpen] = useState(false);
|
||
const [welcomeOpen, setWelcomeOpen] = useState(false);
|
||
const [mapFailed, setMapFailed] = useState(false);
|
||
const [locationError, setLocationError] = useState("");
|
||
const mapElement = useRef<HTMLDivElement>(null);
|
||
const map = useRef<LeafletMap | null>(null);
|
||
const tileLayer = useRef<TileLayer | null>(null);
|
||
const markers = useRef(new Map<number, Marker>());
|
||
|
||
useEffect(() => {
|
||
const timeout = window.setTimeout(() => {
|
||
setDrawerOpen(window.innerWidth >= 640);
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem("lewisburg-coffee-favorites") ?? "[]") as unknown;
|
||
if (Array.isArray(saved)) setFavorites(saved.filter((id): id is number => typeof id === "number"));
|
||
setWelcomeOpen(!localStorage.getItem("hasSeenWelcome"));
|
||
} catch {
|
||
setFavorites([]);
|
||
}
|
||
}, 0);
|
||
return () => window.clearTimeout(timeout);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let resizeObserver: ResizeObserver | undefined;
|
||
const savedMarkers = markers.current;
|
||
import("leaflet").then((L) => {
|
||
if (cancelled || !mapElement.current) return;
|
||
const instance = L.map(mapElement.current, {
|
||
center: [40.9645, -76.8845], zoom: 15, zoomControl: false,
|
||
});
|
||
map.current = instance;
|
||
tileLayer.current = L.tileLayer(tiles.street.url, {
|
||
attribution: tiles.street.attribution, maxZoom: 19,
|
||
}).addTo(instance);
|
||
COFFEE_SHOPS.forEach((shop) => {
|
||
const marker = L.marker([shop.lat, shop.lng], {
|
||
title: shop.name,
|
||
icon: L.divIcon({ className: "coffee-mirror-pin", html: "<span></span>", iconSize: [18, 18], iconAnchor: [9, 9] }),
|
||
}).addTo(instance);
|
||
marker.bindTooltip(shop.name);
|
||
marker.on("click", () => {
|
||
setSelected(shop);
|
||
setDrawerOpen(true);
|
||
});
|
||
markers.current.set(shop.id, marker);
|
||
});
|
||
resizeObserver = new ResizeObserver(() => instance.invalidateSize());
|
||
resizeObserver.observe(mapElement.current);
|
||
requestAnimationFrame(() => instance.invalidateSize());
|
||
}).catch(() => { if (!cancelled) setMapFailed(true); });
|
||
return () => {
|
||
cancelled = true;
|
||
resizeObserver?.disconnect();
|
||
map.current?.remove();
|
||
map.current = null;
|
||
tileLayer.current = null;
|
||
savedMarkers.clear();
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!map.current || !tileLayer.current) return;
|
||
const L = tileLayer.current;
|
||
L.setUrl(tiles[style].url);
|
||
L.options.attribution = tiles[style].attribution;
|
||
}, [style]);
|
||
|
||
useEffect(() => {
|
||
if (!selected || !map.current) return;
|
||
const instance = map.current;
|
||
const target = instance.project([selected.lat, selected.lng], 16);
|
||
const offset = window.innerWidth >= 640 && drawerOpen ? 200 : 0;
|
||
instance.flyTo(instance.unproject(target.subtract([offset, 0]), 16), 16, { duration: 0.8 });
|
||
markers.current.forEach((marker, id) => marker.getElement()?.classList.toggle("is-selected", id === selected.id));
|
||
}, [selected, drawerOpen]);
|
||
|
||
const filtered = useMemo(() => COFFEE_SHOPS.filter((shop) =>
|
||
`${shop.name} ${shop.address}`.toLowerCase().includes(query.toLowerCase().trim()),
|
||
), [query]);
|
||
const favoriteShops = filtered.filter((shop) => favorites.includes(shop.id));
|
||
const otherShops = filtered.filter((shop) => !favorites.includes(shop.id));
|
||
|
||
function toggleFavorite(id: number) {
|
||
setFavorites((current) => {
|
||
const next = current.includes(id) ? current.filter((item) => item !== id) : [...current, id];
|
||
localStorage.setItem("lewisburg-coffee-favorites", JSON.stringify(next));
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function closeWelcome() {
|
||
setWelcomeOpen(false);
|
||
localStorage.setItem("hasSeenWelcome", "true");
|
||
}
|
||
|
||
function locate() {
|
||
setLocationError("");
|
||
if (!navigator.geolocation) { setLocationError("Location is unavailable in this browser."); return; }
|
||
navigator.geolocation.getCurrentPosition(
|
||
({ coords }) => map.current?.flyTo([coords.latitude, coords.longitude], 16),
|
||
() => setLocationError("Couldn’t get your location."),
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`coffee-mirror ${style === "satellite" ? "is-satellite" : ""}`}>
|
||
<div ref={mapElement} className="coffee-mirror-map" aria-label="Interactive map of Lewisburg coffee shops" />
|
||
{mapFailed && <p className="coffee-mirror-map-error">The map could not load. You can still browse the coffee shops.</p>}
|
||
|
||
<header className="coffee-mirror-header">
|
||
<button className={`coffee-mirror-icon-button ${drawerOpen ? "is-active" : ""}`} type="button" onClick={() => setDrawerOpen((open) => !open)} aria-label="Toggle coffee shops" aria-expanded={drawerOpen}><PanelLeft size={21} /></button>
|
||
<button className="coffee-mirror-brand" type="button" onClick={() => setWelcomeOpen(true)}>
|
||
<Coffee size={25} />
|
||
<span><strong>Lewisburg Coffee Map</strong><small>Find your perfect brew</small></span>
|
||
</button>
|
||
<span className="coffee-mirror-header-spacer" />
|
||
</header>
|
||
|
||
<aside className={`coffee-mirror-drawer ${drawerOpen || selected ? "is-open" : ""}`} aria-label="Discover coffee shops">
|
||
{selected ? (
|
||
<div className="coffee-mirror-detail">
|
||
<div className="coffee-mirror-photo"><Image src={shopImage(selected)} alt={selected.name} fill sizes="(max-width: 640px) 100vw, 400px" /></div>
|
||
<div className="coffee-mirror-detail-actions">
|
||
<button type="button" onClick={() => toggleFavorite(selected.id)} aria-label={favorites.includes(selected.id) ? "Remove favorite" : "Add favorite"}><Heart size={18} fill={favorites.includes(selected.id) ? "currentColor" : "none"} /></button>
|
||
<button type="button" onClick={() => setSelected(null)} aria-label="Back to coffee shops"><ChevronLeft size={20} /></button>
|
||
<button type="button" onClick={() => { setSelected(null); setDrawerOpen(false); }} aria-label="Close panel"><X size={19} /></button>
|
||
</div>
|
||
<div className="coffee-mirror-detail-copy">
|
||
<h2>{selected.name}</h2>
|
||
<p className="coffee-mirror-address"><MapPin size={17} /> {selected.address}</p>
|
||
<p>{selected.description}</p>
|
||
<a className="coffee-mirror-directions" href={`https://www.google.com/maps/dir/?api=1&destination=${selected.lat},${selected.lng}`} target="_blank" rel="noreferrer">Get Directions ↗</a>
|
||
<div className="coffee-mirror-detail-links"><a href={selected.website} target="_blank" rel="noreferrer">Website ↗</a><a href={`tel:${selected.phone.replace(/[^+\d]/g, "")}`}>Call</a></div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="coffee-mirror-discovery">
|
||
<div className="coffee-mirror-discovery-top">
|
||
<h2><Coffee size={23} /> Coffee Shops</h2>
|
||
<label className="coffee-mirror-search"><Search size={17} /><input type="search" placeholder="Search coffee shops..." value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search coffee shops" /></label>
|
||
</div>
|
||
<div className="coffee-mirror-list">
|
||
{favoriteShops.length > 0 && <><p className="coffee-mirror-list-label">♥ Favorites</p>{favoriteShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite onSelect={setSelected} onFavorite={toggleFavorite} />)}<hr /></>}
|
||
{favoriteShops.length > 0 && <p className="coffee-mirror-list-label">All Shops</p>}
|
||
{otherShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite={false} onSelect={setSelected} onFavorite={toggleFavorite} />)}
|
||
{filtered.length === 0 && <p className="coffee-mirror-empty">No shops found matching “{query}”</p>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
|
||
<div className="coffee-mirror-controls">
|
||
{locationError && <span className="coffee-mirror-location-error" role="status">{locationError}</span>}
|
||
<button type="button" onClick={locate} aria-label="Find my location"><LocateFixed size={20} /></button>
|
||
<button type="button" onClick={() => map.current?.zoomIn()} aria-label="Zoom in"><Plus size={20} /></button>
|
||
<button type="button" onClick={() => map.current?.zoomOut()} aria-label="Zoom out"><Minus size={20} /></button>
|
||
<div className="coffee-mirror-style-wrap">
|
||
<button type="button" onClick={() => setStyleOpen((open) => !open)} aria-label="Change map style" aria-expanded={styleOpen}><Layers size={20} /></button>
|
||
{styleOpen && <div className="coffee-mirror-style-menu">{(["street", "satellite"] as const).map((option) => <button key={option} type="button" aria-pressed={style === option} onClick={() => { setStyle(option); setStyleOpen(false); }}>{option.charAt(0).toUpperCase() + option.slice(1)}</button>)}</div>}
|
||
</div>
|
||
</div>
|
||
|
||
<footer className="coffee-mirror-footer"><span>© {new Date().getFullYear()} Sean O’Connor</span><span>Map data © OpenStreetMap contributors</span></footer>
|
||
|
||
{welcomeOpen && <div className="coffee-mirror-modal-backdrop" onClick={closeWelcome}><div className="coffee-mirror-modal" role="dialog" aria-modal="true" aria-labelledby="coffee-welcome-title" onClick={(event) => event.stopPropagation()}><button className="coffee-mirror-modal-close" type="button" onClick={closeWelcome} aria-label="Close welcome"><X size={20} /></button><Coffee size={34} /><h2 id="coffee-welcome-title">Welcome to the Lewisburg Coffee Map</h2><p>Discover the best coffee spots in Lewisburg, PA.</p><small>Created by Sean O’Connor</small><ul><li>Explore the map and select a coffee shop.</li><li>Search the list to find your next cup.</li><li>Use the location button to see what’s nearby.</li></ul><button className="coffee-mirror-start" type="button" onClick={closeWelcome}>Start Exploring</button></div></div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ShopRow({ shop, favorite, onSelect, onFavorite }: { shop: CoffeeShop; favorite: boolean; onSelect: (shop: CoffeeShop) => void; onFavorite: (id: number) => void }) {
|
||
return <div className="coffee-mirror-row"><button type="button" className="coffee-mirror-row-main" onClick={() => onSelect(shop)}><Image src={shopImage(shop)} alt="" width={63} height={63} /><span><strong>{shop.name}</strong><small>{shop.address}</small></span></button><button type="button" className="coffee-mirror-favorite" onClick={() => onFavorite(shop.id)} aria-label={favorite ? `Remove ${shop.name} from favorites` : `Add ${shop.name} to favorites`}><Heart size={17} fill={favorite ? "currentColor" : "none"} /></button></div>;
|
||
}
|