- 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.
111 lines
3.8 KiB
TypeScript
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
|
|
});
|
|
}
|