SkateSpots/src/hooks/useFoodSpots.ts
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS
  guard EventSponsors already uses.
- Official SkateSpots badge only shows when the NIP-05 actually verifies
  (NIP19Page), not on a self-asserted handle.
- Community stats reads are null-guarded on persisted-cache fields
  (monthly/cumulativeSpots/topMappers) — no more crash on an old cache.
- Overpass response no longer assumes .elements (food layer).
- user-listings query key is memoized (was Date.now() → refetch storm).
- Pro grant requires the l=pro label (was accepted with none).
- Verify card RSVP maps accepted/tentative (was going/interested → showed
  'No RSVP' on a valid ticket).
- Expired listings can't be bought via a stale/shared URL.
- Batch spot photo/status hooks include the relay suffix and use
  querySkateContent (relay-policy parity with useSpotReports).
- Offline queue requeues rows stuck in 'publishing' after a crash.
- querySkateContent/queryCanonical dedupe replaceable events by author+kind
  (not id) so a stale profile/relay list can't win.
- Route offline cache key includes the origin (was destination+mode only).
- Internal links use <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
2026-10-02 22:26:46 +07:00

111 lines
3.8 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import ngeohash from 'ngeohash';
import type { FoodSpot } from '@/lib/types';
const CORS_PROXY = 'https://proxy.shakespeare.diy/?url=';
const OVERPASS_API = 'https://overpass-api.de/api/interpreter';
interface OverpassElement {
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export function useFoodSpots(geohash: string | undefined, radiusKm = 1) {
return useQuery({
queryKey: ['food-spots', geohash, radiusKm],
queryFn: async () => {
if (!geohash) return [];
// Decode geohash to get center coordinates
const { latitude: centerLat, longitude: centerLon } = ngeohash.decode(geohash);
// Calculate bounding box for the search area
const latOffset = radiusKm / 111; // ~111km per degree of latitude
const lonOffset = radiusKm / (111 * Math.cos((centerLat * Math.PI) / 180));
const south = centerLat - latOffset;
const west = centerLon - lonOffset;
const north = centerLat + latOffset;
const east = centerLon + lonOffset;
// Overpass QL query for restaurants, cafes, fast food, etc.
const query = `
[out:json][timeout:25];
(
node["amenity"~"restaurant|cafe|fast_food|bar|pub|food_court"](${south},${west},${north},${east});
way["amenity"~"restaurant|cafe|fast_food|bar|pub|food_court"](${south},${west},${north},${east});
);
out center;
`;
const response = await fetch(CORS_PROXY + encodeURIComponent(OVERPASS_API), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `data=${encodeURIComponent(query)}`,
});
if (!response.ok) {
throw new Error('Failed to fetch food spots');
}
const data = await response.json();
// Overpass returns an error/status payload without `elements` on failure;
// never assume it exists (a bare `.map` crashed the food layer).
const elements: OverpassElement[] = Array.isArray(data?.elements) ? data.elements : [];
const foodSpots: FoodSpot[] = elements
.map((element: OverpassElement): FoodSpot | null => {
const lat = element.lat ?? element.center?.lat;
const lon = element.lon ?? element.center?.lon;
const tags = element.tags ?? {};
if (!lat || !lon) return null;
const distance = calculateDistance(centerLat, centerLon, lat, lon);
return {
id: `${element.type}-${element.id}`,
name: tags.name || 'Unknown',
lat,
lng: lon,
type: tags.amenity || 'restaurant',
cuisine: tags.cuisine,
address: [tags['addr:housenumber'], tags['addr:street'], tags['addr:city']]
.filter(Boolean)
.join(' '),
phone: tags.phone,
website: tags.website,
openingHours: tags.opening_hours,
distance,
};
})
.filter((s): s is FoodSpot => s !== null)
.sort((a: FoodSpot, b: FoodSpot) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
return foodSpots as FoodSpot[];
},
enabled: !!geohash,
staleTime: 300000, // 5 minutes
});
}