← back to Stayclaim
src/app/stars/nearby/StarsNearbyClient.tsx
225 lines
'use client';
import { useEffect, useRef, useState } from 'react';
const METERS_PER_MILE = 1609.344;
function isSafeMapsUrl(u: string): boolean {
// Block javascript:/data:/vbscript: schemes; only allow https google maps URLs.
try {
const parsed = new URL(u);
return parsed.protocol === 'https:' && parsed.hostname.endsWith('google.com');
} catch {
return false;
}
}
type NearbyStar = {
kind: 'walk' | 'residence' | 'restaurant';
name: string;
subtitle: string;
slug: string;
lat: number;
lng: number;
distance_m: number;
};
const KIND_LABEL: Record<NearbyStar['kind'], string> = {
walk: 'Walk',
residence: 'Residence',
restaurant: 'Restaurant',
};
const KIND_COLOR: Record<NearbyStar['kind'], string> = {
walk: '#c9292e',
residence: '#1f4d40',
restaurant: '#7a4a1f',
};
type State =
| { kind: 'idle' }
| { kind: 'asking' }
| { kind: 'denied'; msg: string }
| { kind: 'loading'; lat: number; lng: number }
| { kind: 'ok'; lat: number; lng: number; stars: NearbyStar[] };
function metersToFeet(m: number): string {
if (m < 100) return `${Math.round(m * 3.28)}ft`;
// Round to nearest 100ft AFTER converting (not before), else 200m → "700ft" (off by 7%)
if (m < 1609) return `${Math.round(m * 3.28 / 100) * 100}ft`;
return `${(m / 1609).toFixed(1)}mi`;
}
function mapsLink(name: string, lat: number, lng: number): string {
// query_place_id requires an actual Google Place ID, not a name; without it
// Google ignores the parameter and the name disambiguation is lost. Pass the
// name in the search query so the map opens with both the coords pin AND
// a labeled result.
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${name} ${lat},${lng}`)}`;
}
export function StarsNearbyClient() {
const [state, setState] = useState<State>({ kind: 'idle' });
const [radiusMi, setRadiusMi] = useState(1);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// Only fire when transitioning into 'loading' — avoids re-running on every
// state change (e.g. when transitioning to 'ok').
const loadKey = state.kind === 'loading' ? `${state.lat},${state.lng},${radiusMi}` : null;
useEffect(() => {
if (state.kind !== 'loading') return;
const ac = new AbortController();
(async () => {
try {
const r = await fetch('/api/stars/nearby', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lat: state.lat, lng: state.lng, radius: radiusMi * METERS_PER_MILE }),
signal: ac.signal,
});
const j = await r.json().catch(() => null);
if (ac.signal.aborted || !mountedRef.current) return;
if (!r.ok) {
setState({ kind: 'denied', msg: j?.error ?? `lookup failed (HTTP ${r.status})` });
return;
}
const stars = Array.isArray(j?.stars) ? j.stars : [];
setState({ kind: 'ok', lat: state.lat, lng: state.lng, stars });
} catch (e: unknown) {
if (ac.signal.aborted || !mountedRef.current) return;
if (e instanceof DOMException && e.name === 'AbortError') return;
setState({ kind: 'denied', msg: 'lookup failed' });
}
})();
return () => { ac.abort(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadKey]);
function requestLocation() {
// Double-click guard — ignore extra clicks while in-flight.
if (state.kind === 'asking' || state.kind === 'loading') return;
if (!('geolocation' in navigator)) {
setState({ kind: 'denied', msg: "your browser doesn't support geolocation" });
return;
}
setState({ kind: 'asking' });
navigator.geolocation.getCurrentPosition(
pos => {
if (!mountedRef.current) return;
const { latitude: lat, longitude: lng } = pos.coords;
// Reject NaN/Infinity and the (0,0) null-island sentinel.
if (!Number.isFinite(lat) || !Number.isFinite(lng) || (lat === 0 && lng === 0)) {
setState({ kind: 'denied', msg: 'invalid coordinates' });
return;
}
setState({ kind: 'loading', lat, lng });
},
err => {
if (!mountedRef.current) return;
setState({ kind: 'denied', msg: err.message });
},
{ enableHighAccuracy: true, maximumAge: 60_000, timeout: 12_000 }
);
}
return (
<section className="max-w-3xl mx-auto px-6 py-10">
{state.kind === 'idle' && (
<div className="text-center">
<button
onClick={requestLocation}
disabled={state.kind !== 'idle'}
className="bg-[#c9292e] text-white px-6 py-4 text-sm uppercase tracking-[0.2em] font-mono font-bold hover:opacity-90 transition disabled:opacity-50"
>
Use my location →
</button>
<p className="mt-4 text-[12px] font-mono uppercase tracking-[0.15em] text-ink/45">
Your coordinates are sent to our server only to filter the list. We don’t log or store them.
</p>
</div>
)}
{state.kind === 'asking' && (
<p className="text-center font-mono uppercase tracking-[0.18em] text-[10px] text-ink/55 py-12">
Asking your browser for location…
</p>
)}
{state.kind === 'denied' && (
<div className="border border-[#c9292e]/40 bg-[#c9292e]/5 p-6 text-center">
<p className="font-display text-2xl text-ink mb-2">Couldn’t get your location.</p>
<p className="text-sm text-ink/65 mb-4">{state.msg}</p>
<button onClick={requestLocation} className="bg-ink text-white px-4 py-2 text-xs uppercase tracking-[0.2em] font-mono">
Try again
</button>
</div>
)}
{state.kind === 'loading' && (
<p className="text-center font-mono uppercase tracking-[0.18em] text-[10px] text-ink/55 py-12">
Finding stars within {radiusMi}mi…
</p>
)}
{state.kind === 'ok' && (
<>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<p className="font-mono text-[11px] uppercase tracking-[0.15em] text-ink/55">
{state.stars.length} stars within {radiusMi}mi · {state.lat.toFixed(2)}, {state.lng.toFixed(2)}
</p>
<label className="flex items-center gap-2 text-[10px] uppercase tracking-[0.15em] text-ink/55 font-mono">
Radius
<select
value={radiusMi}
onChange={e => { setRadiusMi(Number(e.target.value)); setState({ kind: 'loading', lat: state.lat, lng: state.lng }); }}
className="border border-ink/20 px-2 py-1 text-xs bg-white"
>
<option value={0.25}>0.25 mi</option>
<option value={0.5}>0.5 mi</option>
<option value={1}>1 mi</option>
<option value={2}>2 mi</option>
<option value={5}>5 mi</option>
</select>
</label>
</div>
{state.stars.length === 0 ? (
<p className="font-display italic text-2xl text-ink/55 text-center py-12">
No stars within walking distance. Try a wider radius.
</p>
) : (
<ol className="divide-y divide-ink/10">
{state.stars.map(s => (
<li key={`${s.kind}-${s.slug}`}>
<a
href={(() => { const u = mapsLink(s.name, s.lat, s.lng); return isSafeMapsUrl(u) ? u : '#'; })()}
target="_blank"
rel="noopener noreferrer"
className="grid grid-cols-[1fr_auto] gap-3 py-4 items-baseline group"
>
<div>
<span className="inline-block text-[9px] uppercase tracking-[0.18em] font-mono font-bold mr-2 px-2 py-0.5 align-middle"
style={{ background: KIND_COLOR[s.kind], color: 'white' }}>
{KIND_LABEL[s.kind]}
</span>
<span className="font-display text-2xl text-ink group-hover:text-coral transition">
{s.name}
</span>
<p className="text-sm text-ink/65 mt-0.5">{s.subtitle}</p>
</div>
<div className="text-right">
<p className="font-mono text-sm text-ink tabular-nums">{metersToFeet(s.distance_m)}</p>
<p className="font-mono text-[10px] uppercase tracking-[0.15em] text-ink/45 mt-0.5">walk</p>
</div>
</a>
</li>
))}
</ol>
)}
</>
)}
</section>
);
}