import ngeohash from 'ngeohash'; import type { SkateCrew } from '@/lib/types'; /** * Shared, pure helpers for the Crews feature. * * These are intentionally UI-agnostic so the Crews page, crew cards, spot pages * and the crew detail view can all share the same "is this crew alive / near me / * legit" logic without duplicating it. */ // ─── Geo / proximity ───────────────────────────────────────────────────────── export interface LatLng { lat: number; lng: number; } /** Decode a geohash to a lat/lng centre, or null if invalid. */ export function geohashToLatLng(geohash?: string): LatLng | null { if (!geohash) return null; try { const { latitude, longitude } = ngeohash.decode(geohash); if (Number.isNaN(latitude) || Number.isNaN(longitude)) return null; return { lat: latitude, lng: longitude }; } catch { return null; } } /** * Great-circle distance between two points, in kilometres (haversine). */ export function haversineKm(a: LatLng, b: LatLng): number { const R = 6371; // Earth radius km const dLat = ((b.lat - a.lat) * Math.PI) / 180; const dLng = ((b.lng - a.lng) * Math.PI) / 180; const lat1 = (a.lat * Math.PI) / 180; const lat2 = (b.lat * Math.PI) / 180; const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); } /** Distance in km from a point to a crew's home turf, or null if the crew has no geo. */ export function crewDistanceKm(crew: SkateCrew, from: LatLng): number | null { const to = geohashToLatLng(crew.geohash); if (!to) return null; return haversineKm(from, to); } /** Format a km distance for display ("120 m", "3.4 km", "88 km"). */ export function formatDistance(km: number): string { if (km < 1) return `${Math.round(km * 1000)} m`; if (km < 10) return `${km.toFixed(1)} km`; return `${Math.round(km)} km`; } // ─── Activity / health ─────────────────────────────────────────────────────── export type CrewActivityLevel = 'hot' | 'active' | 'quiet' | 'dormant'; export interface CrewActivity { level: CrewActivityLevel; /** Most recent signal (feed post / event / crew update) as unix seconds, if any. */ lastActiveAt?: number; /** Number of upcoming events hosted by the crew. */ upcomingEvents: number; } const DAY = 24 * 60 * 60; /** * Derive an activity level from the freshest signal we have about a crew. * * @param lastActiveAt unix seconds of the most recent feed post / event / edit * @param upcomingEvents number of future crew-hosted events */ export function deriveActivity( lastActiveAt: number | undefined, upcomingEvents: number, ): CrewActivity { const now = Math.floor(Date.now() / 1000); const age = lastActiveAt ? now - lastActiveAt : Infinity; let level: CrewActivityLevel; if (upcomingEvents > 0 || age <= 7 * DAY) { level = 'hot'; } else if (age <= 30 * DAY) { level = 'active'; } else if (age <= 120 * DAY) { level = 'quiet'; } else { level = 'dormant'; } return { level, lastActiveAt, upcomingEvents }; } export const ACTIVITY_META: Record< CrewActivityLevel, { label: string; emoji: string; dot: string; text: string } > = { hot: { label: 'Active this week', emoji: '🔥', dot: 'bg-orange-500', text: 'text-orange-400' }, active: { label: 'Active', emoji: '🛹', dot: 'bg-teal-500', text: 'text-teal-400' }, quiet: { label: 'Quiet', emoji: '💤', dot: 'bg-gray-500', text: 'text-gray-400' }, dormant: { label: 'Dormant', emoji: '🕸️', dot: 'bg-gray-700', text: 'text-gray-600' }, }; // ─── Vibe colour accents ─────────────────────────────────────────────────────── export interface VibeAccent { /** Tailwind text colour for the crew name / icons. */ text: string; /** Tailwind border colour for the card. */ border: string; /** Tailwind hover border colour. */ hoverBorder: string; /** Tailwind background tint. */ bg: string; /** Raw hex for inline glows / gradients. */ hex: string; } const ACCENT_PALETTE: VibeAccent[] = [ { text: 'text-teal-300', border: 'border-teal-500/25', hoverBorder: 'hover:border-teal-500/60', bg: 'bg-teal-500/[0.06]', hex: '#14b8a6' }, { text: 'text-orange-300', border: 'border-orange-500/25', hoverBorder: 'hover:border-orange-500/60', bg: 'bg-orange-500/[0.06]', hex: '#f97316' }, { text: 'text-fuchsia-300', border: 'border-fuchsia-500/25', hoverBorder: 'hover:border-fuchsia-500/60', bg: 'bg-fuchsia-500/[0.06]', hex: '#d946ef' }, { text: 'text-lime-300', border: 'border-lime-500/25', hoverBorder: 'hover:border-lime-500/60', bg: 'bg-lime-500/[0.06]', hex: '#84cc16' }, { text: 'text-sky-300', border: 'border-sky-500/25', hoverBorder: 'hover:border-sky-500/60', bg: 'bg-sky-500/[0.06]', hex: '#0ea5e9' }, { text: 'text-amber-300', border: 'border-amber-500/25', hoverBorder: 'hover:border-amber-500/60', bg: 'bg-amber-500/[0.06]', hex: '#f59e0b' }, { text: 'text-violet-300', border: 'border-violet-500/25', hoverBorder: 'hover:border-violet-500/60', bg: 'bg-violet-500/[0.06]', hex: '#8b5cf6' }, { text: 'text-rose-300', border: 'border-rose-500/25', hoverBorder: 'hover:border-rose-500/60', bg: 'bg-rose-500/[0.06]', hex: '#f43f5e' }, ]; /** Deterministically map a crew to a stable accent colour (so the grid isn't all teal). */ export function crewAccent(crew: Pick): VibeAccent { const seed = `${crew.pubkey}:${crew.identifier}`; let hash = 0; for (let i = 0; i < seed.length; i++) { hash = (hash * 31 + seed.charCodeAt(i)) | 0; } const idx = Math.abs(hash) % ACCENT_PALETTE.length; return ACCENT_PALETTE[idx]; } // ─── Reputation / badges ─────────────────────────────────────────────────────── export type CrewBadge = 'og' | 'verified' | 'established' | 'crew'; export interface CrewBadgeInfo { key: CrewBadge; label: string; emoji: string; } /** * Roll a crew's "legitimacy signals" into a single headline badge. * * Priority (highest first): * - **OG**: founded 5+ years ago AND has confirmed members. * - **Verified**: has a NIP-05 handle (verification is checked separately in the UI). * - **Established**: 3+ members. * - **crew**: default. * * `confirmedMembers` is optional — pass the count of members who vouched in for a * stronger OG signal; falls back to claimed member count. */ export function crewBadge( crew: Pick, confirmedMembers?: number, ): CrewBadgeInfo { const memberCount = confirmedMembers ?? crew.members.length; const year = crew.founded ? parseInt(crew.founded, 10) : NaN; const currentYear = new Date().getFullYear(); const ageYears = Number.isNaN(year) ? 0 : currentYear - year; if (ageYears >= 5 && memberCount > 0) { return { key: 'og', label: 'OG Crew', emoji: '👑' }; } if (crew.nip05) { return { key: 'verified', label: 'Verified', emoji: '✅' }; } if (memberCount >= 3) { return { key: 'established', label: 'Established', emoji: '⭐' }; } return { key: 'crew', label: 'Crew', emoji: '🛹' }; }