← back to Stayclaim

src/components/StreetViewHistory.tsx

194 lines

'use client';
import { useEffect, useRef, useState } from 'react';

/**
 * StreetViewHistory — year-by-year Google Street View captures of an address.
 *
 * Activates the moment NEXT_PUBLIC_GOOGLE_MAPS_API_KEY is set in env.
 * Without the key, renders an editorial "history pending" card that
 * explains what's needed and links out to the live Street View where
 * the user can use Google's built-in time-machine icon.
 *
 * Why we need a key: the JS Maps API exposes
 * StreetViewService.getPanorama() and pano.time/imageDate fields, which
 * are the only way to enumerate historical captures. The Static API
 * endpoint then serves a thumbnail per pano ID. Both endpoints are
 * key-required (typical cost: $7/1k SV requests; year-by-year strip
 * for one address is ~6-12 requests).
 */

type GoogleNs = typeof window & {
  google?: {
    maps?: {
      StreetViewService?: new () => StreetViewService;
      StreetViewPreference?: { NEAREST: string };
      StreetViewSource?: { OUTDOOR: string };
      LatLng?: new (lat: number, lng: number) => unknown;
    };
  };
};

type StreetViewService = {
  getPanorama(req: unknown, cb: (data: PanoData | null, status: string) => void): void;
};

type PanoData = {
  location?: { pano: string; latLng?: { lat: () => number; lng: () => number } };
  imageDate?: string;
  time?: { pano: string; imageDate: string }[];
};

type Capture = { pano: string; year: number; month?: number; date: string };

const KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';

function loadGoogleMaps(apiKey: string): Promise<void> {
  if (typeof window === 'undefined') return Promise.resolve();
  const w = window as GoogleNs;
  if (w.google?.maps?.StreetViewService) return Promise.resolve();
  return new Promise((resolve, reject) => {
    const existing = document.querySelector<HTMLScriptElement>('script[data-stayclaim-gmaps]');
    if (existing) {
      existing.addEventListener('load', () => resolve());
      existing.addEventListener('error', reject);
      return;
    }
    const s = document.createElement('script');
    s.dataset.stayclaimGmaps = 'true';
    s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&v=weekly&libraries=streetView`;
    s.async = true;
    s.onload = () => resolve();
    s.onerror = () => reject(new Error('Failed to load Google Maps JS'));
    document.head.appendChild(s);
  });
}

export function StreetViewHistory({ lat, lng }: { lat: number; lng: number }) {
  const [captures, setCaptures] = useState<Capture[] | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const ranRef = useRef(false);

  useEffect(() => {
    if (!KEY || ranRef.current) return;
    ranRef.current = true;
    setLoading(true);
    (async () => {
      try {
        await loadGoogleMaps(KEY);
        const w = window as GoogleNs;
        if (!w.google?.maps?.StreetViewService || !w.google.maps.LatLng) {
          throw new Error('Google Maps StreetViewService not available');
        }
        const svc = new w.google.maps.StreetViewService();
        const loc = new w.google.maps.LatLng(lat, lng);
        const data: PanoData | null = await new Promise((resolve, reject) => {
          svc.getPanorama(
            { location: loc, radius: 50, source: w.google!.maps!.StreetViewSource?.OUTDOOR ?? 'outdoor' },
            (d: PanoData | null, status: string) => {
              if (status === 'OK' && d) resolve(d);
              else reject(new Error(`SV status: ${status}`));
            }
          );
        });
        const list: Capture[] = [];
        if (data?.time?.length) {
          for (const t of data.time) {
            if (!t.pano || !t.imageDate) continue;
            const [y, m] = t.imageDate.split('-').map(Number);
            list.push({ pano: t.pano, year: y!, month: m, date: t.imageDate });
          }
        } else if (data?.location?.pano && data.imageDate) {
          const [y, m] = data.imageDate.split('-').map(Number);
          list.push({ pano: data.location.pano, year: y!, month: m, date: data.imageDate });
        }
        list.sort((a, b) => b.date.localeCompare(a.date));
        setCaptures(list);
      } catch (e) {
        setError((e as Error).message);
      } finally {
        setLoading(false);
      }
    })();
  }, [lat, lng]);

  // No API key — editorial placeholder.
  if (!KEY) {
    return (
      <section className="my-10">
        <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
          <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Through the years</h2>
          <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
            history feature · key pending
          </span>
        </div>
        <div className="border border-ink/10 bg-sand p-6">
          <p className="text-base text-ink/80 leading-relaxed max-w-prose">
            Google Street View has captured this property roughly every two years since 2007. Once we
            wire up the Maps API key, this section becomes a year-by-year strip you can scrub — every
            capture in one place, with deep links to the live Street View at that moment.
          </p>
          <p className="mt-3 text-[11px] text-ink/55 leading-relaxed">
            For now, open Street View above and use the clock icon (top-left) to scrub the timeline yourself.
          </p>
        </div>
      </section>
    );
  }

  // With key — render the year strip.
  return (
    <section className="my-10">
      <div className="flex items-baseline justify-between mb-4 flex-wrap gap-2">
        <h2 className="font-display text-3xl text-ink tracking-[-0.01em]">Through the years</h2>
        {captures && (
          <span className="text-[10px] uppercase tracking-[0.15em] text-ink/45">
            {captures.length} {captures.length === 1 ? 'capture' : 'captures'} on record
          </span>
        )}
      </div>
      {loading && (
        <p className="text-sm italic text-ink/55">Loading historical captures…</p>
      )}
      {error && (
        <p className="text-sm italic text-coral">Could not load history: {error}</p>
      )}
      {captures && captures.length === 0 && (
        <p className="text-sm italic text-ink/55">No Street View captures available at this address.</p>
      )}
      {captures && captures.length > 0 && (
        <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
          {captures.map(c => {
            const thumb = `https://maps.googleapis.com/maps/api/streetview?size=400x300&pano=${encodeURIComponent(c.pano)}&fov=90&pitch=0&key=${encodeURIComponent(KEY)}`;
            const open = `https://www.google.com/maps/@?api=1&map_action=pano&pano=${encodeURIComponent(c.pano)}`;
            return (
              <a
                key={c.pano}
                href={open}
                target="_blank"
                rel="noopener noreferrer"
                className="group block border border-ink/10 bg-ink overflow-hidden hover:border-coral transition"
              >
                <div className="relative aspect-[4/3] bg-ink/40">
                  <img
                    src={thumb}
                    alt={`Street View capture from ${c.date}`}
                    loading="lazy"
                    className="absolute inset-0 w-full h-full object-cover"
                  />
                </div>
                <div className="px-3 py-2 flex items-baseline justify-between">
                  <span className="font-display text-lg text-sand">{c.year}</span>
                  {c.month && (
                    <span className="font-mono text-[10px] uppercase text-sand/55">{c.date}</span>
                  )}
                </div>
              </a>
            );
          })}
        </div>
      )}
    </section>
  );
}