SkateSpots/src/lib/crewHelpers.ts
shakespeare.diy 6848a72869 Crews: proximity discovery, activity/rep signals, treasury, hiring pipeline, crew-hosted events + reverse links; add Contacts page
Crews page
- "Near me" geolocation toggle with haversine distance sort + distance chips
- Crew count + cities social-proof stat header
- Deterministic per-crew vibe-colour accents (no more wall of teal)
- Activity pulse ("🔥 active this week") + OG/Verified/Established rep badges
- Rich empty state ("Claim your local scene") instead of dead "No crews yet"

Crew synergies
- Crew treasury: transparent, non-custodial NIP-75 zap goal (kind 9041) with
  live progress bar tallied from zap receipts; funds settle to founder's own
  Lightning wallet — no shared wallet or new wallet settings needed
- Crew-hosted events: host a jam/comp as the crew (event a-tag → crew coord),
  "Hosted by [crew]" on event pages, Crew Events list on the crew page
- Crew ↔ Lab hiring pipeline: "Open Call / looking-for" flags (filmer, editor,
  coach, builder, members) surfaced on the crew page
- Request to join: encrypted NIP-17 DM to founder + admins (no founder-push
  bottleneck, no public join event)
- Reverse links: "Crews that skate here" on spot pages

Contacts (0xchat-style + Armada-style privacy)
- New /contacts page with Crew-mates / Follows / Recent DMs / Saved tabs
- Per-contact quick actions: message, zap, view profile, star
- Private encrypted contacts list (NIP-51 kind 30000, NIP-44 encrypted to self)
- Added to burger menu + desktop More menu (gated to logged-in users)

Docs: NIP.md updated with crew treasury, crew-hosted events, looking-for tags,
and the private encrypted contacts list.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-25 12:38:45 +07:00

189 lines
7.4 KiB
TypeScript

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<SkateCrew, 'identifier' | 'pubkey'>): 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<SkateCrew, 'founded' | 'nip05' | 'members'>,
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: '🛹' };
}