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