← 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&rsquo;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&rsquo;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>
  );
}