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>
189 lines
7.4 KiB
TypeScript
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: '🛹' };
|
|
}
|