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>
This commit is contained in:
parent
5a5acda985
commit
6848a72869
24 changed files with 2142 additions and 61 deletions
75
NIP.md
75
NIP.md
|
|
@ -1752,6 +1752,47 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
||||||
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
|
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
|
||||||
| `website`, `instagram`, `social` | Links |
|
| `website`, `instagram`, `social` | Links |
|
||||||
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
||||||
|
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
|
||||||
|
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
|
||||||
|
|
||||||
|
### Crew-Hosted Events
|
||||||
|
|
||||||
|
A crew can host calendar events (jams, comps, meetups). A crew-hosted event is an
|
||||||
|
ordinary NIP-52 time-based calendar event (kind `31923`, `t: skateboarding`) that
|
||||||
|
carries an `a` tag pointing back at the crew coordinate, plus a readable
|
||||||
|
`crew-name` tag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 31923,
|
||||||
|
"tags": [
|
||||||
|
["d", "bcn-night-jam-2025"],
|
||||||
|
["title", "BCN Night Jam"],
|
||||||
|
["start", "1730000000"],
|
||||||
|
["t", "skateboarding"],
|
||||||
|
["a", "35006:<founder>:<crewDtag>"],
|
||||||
|
["crew-name", "Barcelona Night Crew"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients query a crew's events with `{ kinds: [31923], "#a": ["35006:<founder>:<crewDtag>"] }`
|
||||||
|
and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop.
|
||||||
|
|
||||||
|
### Crew Treasury (NIP-75 Zap Goal)
|
||||||
|
|
||||||
|
A crew may run a transparent, **non-custodial** funding goal for a shared cause
|
||||||
|
(fixing a DIY spot, a crew trip, a comp prize pot). The founder publishes a
|
||||||
|
[NIP-75](https://github.com/nostr-protocol/nips/blob/master/75.md) Zap Goal
|
||||||
|
(kind `9041`) with an `amount` (millisats target) and links it from the crew via
|
||||||
|
a `["goal", "<goalEventId>"]` tag.
|
||||||
|
|
||||||
|
Progress is tallied client-side from zap receipts (kind `9735`) that reference
|
||||||
|
the goal event id. Contributions are ordinary zaps that settle to the goal
|
||||||
|
author's (founder's) own Lightning address — **SkateSpots never custodies
|
||||||
|
funds**; it only reads the public tally. No shared wallet or new wallet settings
|
||||||
|
are required; the payment rail is the founder's existing Lightning address /
|
||||||
|
CLINK noffer, and contributors pay with their in-app wallet (WebLN / NWC / Cashu).
|
||||||
|
|
||||||
### Membership
|
### Membership
|
||||||
|
|
||||||
|
|
@ -2067,3 +2108,37 @@ The `status` tag MUST hold one of the following values:
|
||||||
- Spots with a winning status of `gone` are **hidden from map markers** (but
|
- Spots with a winning status of `gone` are **hidden from map markers** (but
|
||||||
still shown dimmed, with a badge, in card lists) so the map isn't cluttered by
|
still shown dimmed, with a badge, in card lists) so the map isn't cluttered by
|
||||||
demolished spots while preserving the record.
|
demolished spots while preserving the record.
|
||||||
|
|
||||||
|
## Private Contacts (kind 30000, encrypted)
|
||||||
|
|
||||||
|
To let a user maintain a personal "who I skate with" address book **without
|
||||||
|
leaking their social graph publicly** (unlike a kind-3 follow list), SkateSpots
|
||||||
|
stores a private, encrypted contacts list as a NIP-51 Follow Set (kind `30000`)
|
||||||
|
with a fixed `d` identifier `skatespots-contacts`.
|
||||||
|
|
||||||
|
The contact pubkeys are NIP-44 encrypted to the user's **own** key and kept in
|
||||||
|
the event `content` — only the user can read them. This mirrors the "encrypted
|
||||||
|
private list" approach and keeps the scene network self-sovereign and private.
|
||||||
|
|
||||||
|
### Event Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30000,
|
||||||
|
"content": "<NIP-44 encrypted JSON array of p-tag pairs>",
|
||||||
|
"tags": [
|
||||||
|
["d", "skatespots-contacts"],
|
||||||
|
["title", "My Skate Contacts"],
|
||||||
|
["description", "Private, encrypted list of people in my scene."]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client behavior
|
||||||
|
|
||||||
|
- The list is decrypted with the logged-in user's NIP-44 signer. Clients that
|
||||||
|
cannot decrypt (different key) fall back to any public p tags present.
|
||||||
|
- The Contacts page also surfaces crew-mates (members of crews the user belongs
|
||||||
|
to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any
|
||||||
|
of those can be starred into the private list.
|
||||||
|
- The encrypted list is never merged into the public kind-3 follow list.
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const SpotCollectionsPage = lazy(() => import("./pages/SpotCollectionsPage"));
|
||||||
const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage"));
|
const CollectionDetailPage = lazy(() => import("./pages/CollectionDetailPage"));
|
||||||
const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage"));
|
const DirectMessagesPage = lazy(() => import("./pages/DirectMessagesPage"));
|
||||||
const ConversationPage = lazy(() => import("./pages/ConversationPage"));
|
const ConversationPage = lazy(() => import("./pages/ConversationPage"));
|
||||||
|
const ContactsPage = lazy(() => import("./pages/ContactsPage"));
|
||||||
const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage"));
|
const BountyLeaderboardPage = lazy(() => import("./pages/BountyLeaderboardPage"));
|
||||||
const SignersPage = lazy(() => import("./pages/SignersPage"));
|
const SignersPage = lazy(() => import("./pages/SignersPage"));
|
||||||
const StashPage = lazy(() => import("./pages/StashPage"));
|
const StashPage = lazy(() => import("./pages/StashPage"));
|
||||||
|
|
@ -118,6 +119,7 @@ export function AppRouter() {
|
||||||
<Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} />
|
<Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} />
|
||||||
<Route path="/messages" element={<DirectMessagesPage />} />
|
<Route path="/messages" element={<DirectMessagesPage />} />
|
||||||
<Route path="/messages/:npub" element={<ConversationPage />} />
|
<Route path="/messages/:npub" element={<ConversationPage />} />
|
||||||
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
{/* Bounty System */}
|
{/* Bounty System */}
|
||||||
<Route path="/bounties" element={<BountyLeaderboardPage />} />
|
<Route path="/bounties" element={<BountyLeaderboardPage />} />
|
||||||
{/* Security & Signers */}
|
{/* Security & Signers */}
|
||||||
|
|
|
||||||
82
src/components/CrewBadge.tsx
Normal file
82
src/components/CrewBadge.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { crewBadge, ACTIVITY_META, type CrewActivity } from '@/lib/crewHelpers';
|
||||||
|
import type { SkateCrew } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small "legitimacy" badge (OG / Verified / Established) derived from a crew's
|
||||||
|
* founding year, NIP-05 and member count. Social proof for the directory.
|
||||||
|
*/
|
||||||
|
export function CrewRepBadge({
|
||||||
|
crew,
|
||||||
|
confirmedMembers,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
crew: Pick<SkateCrew, 'founded' | 'nip05' | 'members'>;
|
||||||
|
confirmedMembers?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const badge = crewBadge(crew, confirmedMembers);
|
||||||
|
if (badge.key === 'crew') return null;
|
||||||
|
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
og: 'bg-amber-500/15 text-amber-300 border-amber-500/40',
|
||||||
|
verified: 'bg-teal-500/15 text-teal-300 border-teal-500/40',
|
||||||
|
established: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide',
|
||||||
|
styles[badge.key],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={badge.label}
|
||||||
|
>
|
||||||
|
<span className="leading-none">{badge.emoji}</span>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subtle activity pulse ("🔥 Active this week"). Dormant crews render nothing
|
||||||
|
* unless `showDormant` is set, so the grid highlights life rather than death.
|
||||||
|
*/
|
||||||
|
export function CrewActivityPulse({
|
||||||
|
activity,
|
||||||
|
showDormant = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
activity: CrewActivity | undefined;
|
||||||
|
showDormant?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!activity) return null;
|
||||||
|
// In dense card grids we only surface positive signals — hide dormant crews.
|
||||||
|
if (activity.level === 'dormant' && !showDormant) return null;
|
||||||
|
|
||||||
|
const meta = ACTIVITY_META[activity.level];
|
||||||
|
const isHot = activity.level === 'hot';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex items-center gap-1 text-[10px] font-bold', meta.text, className)}
|
||||||
|
title={
|
||||||
|
activity.upcomingEvents > 0
|
||||||
|
? `${activity.upcomingEvents} upcoming event${activity.upcomingEvents === 1 ? '' : 's'}`
|
||||||
|
: meta.label
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
{isHot && (
|
||||||
|
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', meta.dot)} />
|
||||||
|
)}
|
||||||
|
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', meta.dot)} />
|
||||||
|
</span>
|
||||||
|
{activity.upcomingEvents > 0
|
||||||
|
? `${activity.upcomingEvents} event${activity.upcomingEvents === 1 ? '' : 's'} coming`
|
||||||
|
: meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -20,16 +20,20 @@ import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
SKATE_SPOT_KIND,
|
SKATE_SPOT_KIND,
|
||||||
|
ZAP_GOAL_KIND,
|
||||||
CREW_VIBE_LABELS,
|
CREW_VIBE_LABELS,
|
||||||
CREW_FOCUS_LABELS,
|
CREW_FOCUS_LABELS,
|
||||||
CREW_FOCUS_DESCRIPTIONS,
|
CREW_FOCUS_DESCRIPTIONS,
|
||||||
CREW_FOCUS_TAG,
|
CREW_FOCUS_TAG,
|
||||||
|
CREW_LOOKING_FOR_OPTIONS,
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
type SkateCrew,
|
type SkateCrew,
|
||||||
type CrewFocus,
|
type CrewFocus,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles } from 'lucide-react';
|
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone } from 'lucide-react';
|
||||||
|
|
||||||
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching'];
|
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching'];
|
||||||
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||||
|
|
@ -153,6 +157,29 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
|
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
// Open call ("looking for") — Lab craft slugs the crew is hiring for.
|
||||||
|
const [lookingFor, setLookingFor] = useState<string[]>(existing?.lookingFor ?? []);
|
||||||
|
const toggleLookingFor = (slug: string) =>
|
||||||
|
setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]));
|
||||||
|
|
||||||
|
// Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing.
|
||||||
|
const { data: existingGoal } = useCrewGoal(existing?.goalRef);
|
||||||
|
const { config } = useAppContext();
|
||||||
|
const [goalEnabled, setGoalEnabled] = useState(!!existing?.goalRef);
|
||||||
|
const [goalTitle, setGoalTitle] = useState('');
|
||||||
|
const [goalTarget, setGoalTarget] = useState('');
|
||||||
|
const [goalPrefilled, setGoalPrefilled] = useState(false);
|
||||||
|
|
||||||
|
// When an existing goal loads, prefill the fields once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingGoal && !goalPrefilled) {
|
||||||
|
setGoalPrefilled(true);
|
||||||
|
setGoalEnabled(true);
|
||||||
|
setGoalTitle(existingGoal.description || existingGoal.summary || '');
|
||||||
|
setGoalTarget(existingGoal.targetSats ? String(existingGoal.targetSats) : '');
|
||||||
|
}
|
||||||
|
}, [existingGoal, goalPrefilled]);
|
||||||
|
|
||||||
const toggleVibe = (v: string) =>
|
const toggleVibe = (v: string) =>
|
||||||
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
|
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
|
||||||
|
|
||||||
|
|
@ -260,6 +287,34 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
try {
|
try {
|
||||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
// ── Crew treasury: publish/refresh a NIP-75 zap goal, then link it. ──
|
||||||
|
// The goal is non-custodial — zaps settle to the founder's own Lightning
|
||||||
|
// wallet. We only publish the public goal + tally later.
|
||||||
|
let goalRef: string | undefined = existing?.goalRef;
|
||||||
|
const targetSats = parseInt(goalTarget, 10);
|
||||||
|
if (goalEnabled && goalTitle.trim() && targetSats > 0) {
|
||||||
|
const relayUrls = config.relayMetadata.relays.filter((r) => r.write).map((r) => r.url);
|
||||||
|
const needsNewGoal =
|
||||||
|
!existingGoal ||
|
||||||
|
existingGoal.description !== goalTitle.trim() ||
|
||||||
|
existingGoal.targetSats !== targetSats;
|
||||||
|
if (needsNewGoal) {
|
||||||
|
const goalEvent = await createEvent({
|
||||||
|
kind: ZAP_GOAL_KIND,
|
||||||
|
content: goalTitle.trim(),
|
||||||
|
tags: [
|
||||||
|
['amount', String(targetSats * 1000)], // millisats
|
||||||
|
['relays', ...(relayUrls.length ? relayUrls : ['wss://relay.nostr.band'])],
|
||||||
|
['summary', `${name.trim()} crew treasury`],
|
||||||
|
['alt', `Crew funding goal: ${goalTitle.trim()}`],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
goalRef = goalEvent.id;
|
||||||
|
}
|
||||||
|
} else if (!goalEnabled) {
|
||||||
|
goalRef = undefined; // turning the treasury off unlinks the goal
|
||||||
|
}
|
||||||
|
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['d', identifier],
|
['d', identifier],
|
||||||
['title', name.trim()],
|
['title', name.trim()],
|
||||||
|
|
@ -313,6 +368,14 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
tags.push(['a', coord]);
|
tags.push(['a', coord]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crew treasury goal (NIP-75) linkage.
|
||||||
|
if (goalRef) tags.push(['goal', goalRef]);
|
||||||
|
|
||||||
|
// Open call — Lab crafts the crew is looking for.
|
||||||
|
for (const slug of lookingFor) {
|
||||||
|
tags.push(['looking-for', slug]);
|
||||||
|
}
|
||||||
|
|
||||||
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||||
|
|
||||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
||||||
|
|
@ -485,6 +548,104 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Open call — looking for (crew ↔ Lab hiring pipeline) */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
|
||||||
|
<div>
|
||||||
|
<Label className="flex items-center gap-2 text-fuchsia-400">
|
||||||
|
<Megaphone className="h-4 w-4" /> Open Call <span className="font-normal text-gray-500">· optional</span>
|
||||||
|
</Label>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Looking for a filmer, editor or new blood? Put out a call — it shows on your crew page so
|
||||||
|
matching folks from <strong className="text-gray-300">The Lab</strong> can reach out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CREW_LOOKING_FOR_OPTIONS.map((opt) => {
|
||||||
|
const on = lookingFor.includes(opt.slug);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.slug}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLookingFor(opt.slug)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-bold transition-all active:scale-95',
|
||||||
|
on
|
||||||
|
? 'border-fuchsia-500 bg-fuchsia-500/20 text-fuchsia-200'
|
||||||
|
: 'border-gray-700 bg-gray-800 text-gray-300 hover:border-fuchsia-500/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{opt.emoji}</span>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crew treasury (NIP-75 zap goal) */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-orange-500/30 bg-orange-500/[0.04] p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Label className="flex items-center gap-2 text-orange-400">
|
||||||
|
<Target className="h-4 w-4" /> Crew Treasury <span className="font-normal text-gray-500">· optional</span>
|
||||||
|
</Label>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot.
|
||||||
|
It's a transparent Lightning goal; zaps go <strong className="text-gray-300">straight to your wallet</strong>{' '}
|
||||||
|
(via your profile's Lightning address). SkateSpots never holds the funds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={goalEnabled}
|
||||||
|
aria-label="Enable Crew Treasury"
|
||||||
|
onClick={() => setGoalEnabled((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
|
||||||
|
goalEnabled ? 'bg-orange-500' : 'bg-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
|
||||||
|
goalEnabled ? 'translate-x-[22px]' : 'translate-x-0.5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{goalEnabled && (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="goal-title" className="text-xs text-gray-400">What's the goal?</Label>
|
||||||
|
<Input
|
||||||
|
id="goal-title"
|
||||||
|
placeholder="e.g. Concrete the DIY spot"
|
||||||
|
value={goalTitle}
|
||||||
|
onChange={(e) => setGoalTitle(e.target.value)}
|
||||||
|
className="bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="goal-target" className="text-xs text-gray-400">Target (sats)</Label>
|
||||||
|
<Input
|
||||||
|
id="goal-target"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="500000"
|
||||||
|
value={goalTarget}
|
||||||
|
onChange={(e) => setGoalTarget(e.target.value)}
|
||||||
|
className="bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="flex items-start gap-1.5 text-[11px] text-gray-500">
|
||||||
|
<Target className="mt-0.5 h-3 w-3 shrink-0 text-orange-400" />
|
||||||
|
Make sure your Nostr profile has a Lightning address (lud16) so contributions can land.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members */}
|
||||||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
39
src/components/CrewLookingFor.tsx
Normal file
39
src/components/CrewLookingFor.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CREW_LOOKING_FOR_LABELS } from '@/lib/types';
|
||||||
|
import { Megaphone } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a crew's open calls ("looking for a filmer / editor / new members").
|
||||||
|
* This is the crew ↔ Lab hiring pipeline: crews broadcast what they need, and
|
||||||
|
* matching Lab providers can reach out. Slugs map to Lab scenes.
|
||||||
|
*/
|
||||||
|
export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) {
|
||||||
|
if (!lookingFor || lookingFor.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-fuchsia-500/30 bg-black/90">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<Megaphone className="h-5 w-5 text-fuchsia-400" /> Open Call
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-xs text-gray-400">This crew is looking for:</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{lookingFor.map((slug) => {
|
||||||
|
const meta = CREW_LOOKING_FOR_LABELS[slug];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={slug}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1.5 text-sm font-bold text-fuchsia-200"
|
||||||
|
>
|
||||||
|
<span>{meta?.emoji ?? '🛹'}</span>
|
||||||
|
{meta?.label ?? slug}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/components/CrewRequestToJoin.tsx
Normal file
133
src/components/CrewRequestToJoin.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { UserPlus, Loader2, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CrewRequestToJoinProps {
|
||||||
|
crewName: string;
|
||||||
|
founderPubkey: string;
|
||||||
|
/** Delegated admins — they also receive the request so growth isn't founder-only. */
|
||||||
|
admins?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a skater request to join a crew via an encrypted NIP-17 DM to the
|
||||||
|
* founder and admins. This removes the founder-push-only bottleneck: anyone can
|
||||||
|
* knock, and any admin can then add them from the crew editor.
|
||||||
|
*
|
||||||
|
* Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the
|
||||||
|
* recipients can read it. No public "join" event leaks the social graph.
|
||||||
|
*/
|
||||||
|
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const recipients = Array.from(new Set([founderPubkey, ...admins])).filter(
|
||||||
|
(pk) => pk !== user.pubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't show for people already in the leadership.
|
||||||
|
if (recipients.length === 0) return null;
|
||||||
|
|
||||||
|
const canDM = !!user.signer?.nip44;
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!canDM) {
|
||||||
|
toast({
|
||||||
|
title: 'Encryption unavailable',
|
||||||
|
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
`🛹 Request to join ${crewName}` +
|
||||||
|
(note.trim() ? `\n\n${note.trim()}` : '\n\nWould love to roll with the crew.');
|
||||||
|
await sendMessage({
|
||||||
|
recipients,
|
||||||
|
content: body,
|
||||||
|
subject: `${crewName} — join request`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Request sent!',
|
||||||
|
description: `The ${crewName} crew leadership got your encrypted message.`,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setNote('');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not send request',
|
||||||
|
description: err instanceof Error ? err.message : 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5" /> Request to join
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crewName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="flex items-center gap-1.5">
|
||||||
|
<Lock className="h-3 w-3 text-teal-400" />
|
||||||
|
Sends a private, encrypted message to the crew's founder & admins.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Say hi — where you skate, who you know, why you want in…"
|
||||||
|
rows={4}
|
||||||
|
maxLength={1000}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-teal-500 font-bold text-black hover:bg-teal-600"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" /> Send join request
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/CrewTreasuryCard.tsx
Normal file
116
src/components/CrewTreasuryCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crew treasury — a transparent, zap-funded NIP-75 goal. Contributions are
|
||||||
|
* ordinary zaps to the goal event that settle to the founder's own Lightning
|
||||||
|
* wallet; SkateSpots never custodies funds. Progress is tallied from public
|
||||||
|
* zap receipts, so anyone can audit it.
|
||||||
|
*/
|
||||||
|
export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
|
||||||
|
const { data: goal, isLoading } = useCrewGoal(goalId);
|
||||||
|
|
||||||
|
if (!goalId) return null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-black/90 border-2 border-orange-500/30">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<Skeleton className="h-3 w-2/3" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!goal) return null;
|
||||||
|
|
||||||
|
const pct = Math.min(100, goal.percent);
|
||||||
|
const reached = goal.percent >= 100;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const closed = goal.closedAt ? goal.closedAt < now : false;
|
||||||
|
const daysLeft = goal.closedAt ? Math.ceil((goal.closedAt - now) / 86400) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden border-2 bg-black/90',
|
||||||
|
reached ? 'border-green-500/50' : 'border-orange-500/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<Target className={cn('h-5 w-5', reached ? 'text-green-400' : 'text-orange-400')} />
|
||||||
|
Crew Treasury
|
||||||
|
</CardTitle>
|
||||||
|
{goal.summary && <p className="text-xs text-gray-400">{goal.summary}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{goal.description && (
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-gray-300">{goal.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-end justify-between text-sm">
|
||||||
|
<span className={cn('font-black', reached ? 'text-green-400' : 'text-orange-400')}>
|
||||||
|
{goal.raisedSats.toLocaleString()} sats
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
of {goal.targetSats.toLocaleString()} goal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full overflow-hidden rounded-full bg-white/5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-700',
|
||||||
|
reached
|
||||||
|
? 'bg-gradient-to-r from-green-500 to-emerald-400'
|
||||||
|
: 'bg-gradient-to-r from-orange-500 to-amber-400',
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{reached ? (
|
||||||
|
<span className="flex items-center gap-1 font-bold text-green-400">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Goal reached!
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-orange-300">{goal.percent}% funded</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{goal.contributions} contribution{goal.contributions === 1 ? '' : 's'}
|
||||||
|
{daysLeft !== null && daysLeft > 0 && ` · ${daysLeft}d left`}
|
||||||
|
{closed && ' · closed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contribute — zaps the goal event; settles to the founder's wallet. */}
|
||||||
|
{!closed && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ZapButton
|
||||||
|
target={goal.event}
|
||||||
|
className="w-full justify-center bg-orange-500 text-black hover:bg-orange-600"
|
||||||
|
/>
|
||||||
|
<p className="flex items-center justify-center gap-1 text-center text-[10px] text-gray-600">
|
||||||
|
<Zap className="h-2.5 w-2.5" />
|
||||||
|
Non-custodial · zaps go straight to the crew's Lightning wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/CrewsForSpotCard.tsx
Normal file
76
src/components/CrewsForSpotCard.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useCrewsForSpot } from '@/hooks/useCrewsForSpot';
|
||||||
|
import { crewAccent } from '@/lib/crewHelpers';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse link on a spot page: "Crews that skate here". Makes the local scene
|
||||||
|
* visible from the spot side, not just from the crew side.
|
||||||
|
*/
|
||||||
|
export function CrewsForSpotCard({
|
||||||
|
spotPubkey,
|
||||||
|
spotIdentifier,
|
||||||
|
}: {
|
||||||
|
spotPubkey?: string;
|
||||||
|
spotIdentifier?: string;
|
||||||
|
}) {
|
||||||
|
const { data: crews, isLoading } = useCrewsForSpot(spotPubkey, spotIdentifier);
|
||||||
|
|
||||||
|
// Nothing to show and not loading → render nothing (keeps the page clean).
|
||||||
|
if (!isLoading && (!crews || crews.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-teal-500/20 bg-black/90">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<Users className="h-5 w-5 text-teal-400" /> Crews that skate here
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-28" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
crews!.map((crew) => {
|
||||||
|
const accent = crewAccent(crew);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${crew.pubkey}:${crew.identifier}`}
|
||||||
|
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-white/5 bg-black/40 p-2 transition-colors hover:bg-black/60"
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10 rounded-xl border" style={{ borderColor: `${accent.hex}55` }}>
|
||||||
|
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
||||||
|
<AvatarFallback
|
||||||
|
className="rounded-xl text-xs font-black"
|
||||||
|
style={{ color: accent.hex, background: `${accent.hex}22` }}
|
||||||
|
>
|
||||||
|
{crew.name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-white">{crew.name}</p>
|
||||||
|
<p className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Users className="h-3 w-3" /> {crew.members.length} member
|
||||||
|
{crew.members.length === 1 ? '' : 's'}
|
||||||
|
{crew.location && ` · ${crew.location}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -406,6 +406,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{user && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/contacts" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Users className="h-4 w-4 text-teal-400" />
|
||||||
|
<span className="text-teal-400">Contacts</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/collections" className="flex items-center gap-2 cursor-pointer">
|
<Link to="/collections" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
|
@ -634,6 +642,15 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{user && (
|
||||||
|
<NavLink
|
||||||
|
href="/contacts"
|
||||||
|
label="Contacts"
|
||||||
|
icon={Users}
|
||||||
|
isActive={isActive('/contacts')}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<NavLink
|
<NavLink
|
||||||
href="/collections"
|
href="/collections"
|
||||||
|
|
|
||||||
104
src/hooks/useCrewActivity.ts
Normal file
104
src/hooks/useCrewActivity.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import {
|
||||||
|
SKATE_CREW_KIND,
|
||||||
|
CALENDAR_EVENT_KIND,
|
||||||
|
type SkateCrew,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { deriveActivity, type CrewActivity } from '@/lib/crewHelpers';
|
||||||
|
|
||||||
|
/** Build the addressable coordinate for a crew: `35006:pubkey:d`. */
|
||||||
|
export function crewCoordinate(crew: Pick<SkateCrew, 'pubkey' | 'identifier'>): string {
|
||||||
|
return `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity/health signals for a set of crews, in ONE batched query.
|
||||||
|
*
|
||||||
|
* We tally two signals per crew:
|
||||||
|
* 1. **Feed freshness** — the most recent kind-1 note authored by the founder or
|
||||||
|
* a listed member (proxy for "the crew is skating & posting").
|
||||||
|
* 2. **Upcoming events** — future calendar events (kind 31923) that carry an
|
||||||
|
* `a` tag pointing back at the crew coordinate (crew-hosted jams/comps).
|
||||||
|
*
|
||||||
|
* Returns a map keyed by crew coordinate → {@link CrewActivity}.
|
||||||
|
*/
|
||||||
|
export function useCrewsActivity(crews: SkateCrew[] | undefined) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
// Stable key from the crew set (coords only).
|
||||||
|
const coords = (crews ?? []).map(crewCoordinate).sort();
|
||||||
|
const authorKey = (crews ?? [])
|
||||||
|
.flatMap((c) => [c.pubkey, ...c.members])
|
||||||
|
.sort()
|
||||||
|
.join(',')
|
||||||
|
.slice(0, 512);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crews-activity', coords.join(','), authorKey, getQueryKeySuffix()],
|
||||||
|
enabled: !!crews && crews.length > 0,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
const list = crews ?? [];
|
||||||
|
|
||||||
|
// All feed authors (founder + members), de-duplicated.
|
||||||
|
const authors = Array.from(
|
||||||
|
new Set(list.flatMap((c) => [c.pubkey, ...c.members])),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batched query: recent notes from any crew member + crew-hosted events.
|
||||||
|
const [notes, events] = await Promise.all([
|
||||||
|
authors.length > 0
|
||||||
|
? querySkateContent(
|
||||||
|
[{ kinds: [1], authors, limit: 200 }],
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
querySkateContent(
|
||||||
|
[{ kinds: [CALENDAR_EVENT_KIND], '#a': coords, limit: 200 }],
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fastest lookup: author pubkey → newest note timestamp.
|
||||||
|
const newestByAuthor = new Map<string, number>();
|
||||||
|
for (const n of notes) {
|
||||||
|
const prev = newestByAuthor.get(n.pubkey) ?? 0;
|
||||||
|
if (n.created_at > prev) newestByAuthor.set(n.pubkey, n.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crew coord → { upcoming count, newest event ts }
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const eventStats = new Map<string, { upcoming: number; newest: number }>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const startTag = ev.tags.find(([t]) => t === 'start')?.[1];
|
||||||
|
const start = startTag ? parseInt(startTag, 10) : 0;
|
||||||
|
const crewA = ev.tags
|
||||||
|
.filter(([t]) => t === 'a')
|
||||||
|
.map(([, v]) => v)
|
||||||
|
.find((v) => v?.startsWith(`${SKATE_CREW_KIND}:`));
|
||||||
|
if (!crewA) continue;
|
||||||
|
const cur = eventStats.get(crewA) ?? { upcoming: 0, newest: 0 };
|
||||||
|
if (start >= now) cur.upcoming += 1;
|
||||||
|
if (ev.created_at > cur.newest) cur.newest = ev.created_at;
|
||||||
|
eventStats.set(crewA, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, CrewActivity>();
|
||||||
|
for (const crew of list) {
|
||||||
|
const coord = crewCoordinate(crew);
|
||||||
|
const feedTs = Math.max(
|
||||||
|
crew.createdAt,
|
||||||
|
...[crew.pubkey, ...crew.members].map((pk) => newestByAuthor.get(pk) ?? 0),
|
||||||
|
);
|
||||||
|
const evStat = eventStats.get(coord);
|
||||||
|
const lastActiveAt = Math.max(feedTs, evStat?.newest ?? 0) || undefined;
|
||||||
|
result.set(coord, deriveActivity(lastActiveAt, evStat?.upcoming ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
54
src/hooks/useCrewEvents.ts
Normal file
54
src/hooks/useCrewEvents.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import {
|
||||||
|
SKATE_CREW_KIND,
|
||||||
|
CALENDAR_EVENT_KIND,
|
||||||
|
parseSkateEvent,
|
||||||
|
type SkateEvent,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events hosted BY a crew — calendar events (kind 31923) carrying an `a` tag
|
||||||
|
* that points back at the crew coordinate (`35006:pubkey:d`).
|
||||||
|
*
|
||||||
|
* This powers the "Hosted by [crew]" link on events and the crew's own
|
||||||
|
* "Upcoming jams" list. Crew-hosted events close the Crews ↔ Events ↔ Zaps loop.
|
||||||
|
*/
|
||||||
|
export function useCrewEvents(crewPubkey?: string, crewIdentifier?: string, limit = 30) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const crewCoord =
|
||||||
|
crewPubkey && crewIdentifier
|
||||||
|
? `${SKATE_CREW_KIND}:${crewPubkey}:${crewIdentifier}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crew-events', crewCoord, getQueryKeySuffix()],
|
||||||
|
enabled: !!crewCoord,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [CALENDAR_EVENT_KIND], '#a': [crewCoord], limit }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed: SkateEvent[] = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
const skateEvent = parseSkateEvent(ev);
|
||||||
|
// Guard against loose relay matches — the `a` tag must be present.
|
||||||
|
const hasCrewRef = ev.tags.some(([t, v]) => t === 'a' && v === crewCoord);
|
||||||
|
if (skateEvent && hasCrewRef) parsed.push(skateEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soonest upcoming first, then past.
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return parsed.sort((a, b) => {
|
||||||
|
const aFuture = a.start >= now ? 0 : 1;
|
||||||
|
const bFuture = b.start >= now ? 0 : 1;
|
||||||
|
if (aFuture !== bFuture) return aFuture - bFuture;
|
||||||
|
return a.start - b.start;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
111
src/hooks/useCrewGoal.ts
Normal file
111
src/hooks/useCrewGoal.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { nip57 } from 'nostr-tools';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { ZAP_GOAL_KIND } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface CrewGoal {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
/** Human-readable goal description (content). */
|
||||||
|
description: string;
|
||||||
|
/** Target amount in sats. */
|
||||||
|
targetSats: number;
|
||||||
|
/** Total raised so far in sats (tallied from zap receipts). */
|
||||||
|
raisedSats: number;
|
||||||
|
/** Number of contributions. */
|
||||||
|
contributions: number;
|
||||||
|
/** Optional deadline (unix seconds). */
|
||||||
|
closedAt?: number;
|
||||||
|
/** Percentage complete (0–100+, may exceed 100). */
|
||||||
|
percent: number;
|
||||||
|
image?: string;
|
||||||
|
summary?: string;
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum sats from a set of zap receipts (kind 9735), reusing the bolt11/amount logic. */
|
||||||
|
function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } {
|
||||||
|
let sats = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const z of receipts) {
|
||||||
|
if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps
|
||||||
|
|
||||||
|
const amountTag = z.tags.find(([n]) => n === 'amount')?.[1];
|
||||||
|
if (amountTag) {
|
||||||
|
const ms = parseInt(amountTag, 10);
|
||||||
|
if (!Number.isNaN(ms)) {
|
||||||
|
sats += Math.floor(ms / 1000);
|
||||||
|
count++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1];
|
||||||
|
if (bolt11) {
|
||||||
|
try {
|
||||||
|
sats += nip57.getSatoshisAmountFromBolt11(bolt11);
|
||||||
|
count++;
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
// ignore unparseable invoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sats, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a crew's NIP-75 funding goal (kind 9041) and tally its progress from
|
||||||
|
* zap receipts (kind 9735) that reference the goal event.
|
||||||
|
*
|
||||||
|
* The goal is fully transparent & non-custodial: contributors zap the goal, and
|
||||||
|
* funds settle to the goal author's own Lightning address. SkateSpots never
|
||||||
|
* holds funds — it only reads the public tally.
|
||||||
|
*/
|
||||||
|
export function useCrewGoal(goalId?: string) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crew-goal', goalId],
|
||||||
|
enabled: !!goalId,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: (q) => (q.getObserversCount() > 0 ? 90 * 1000 : false),
|
||||||
|
queryFn: async (c): Promise<CrewGoal | null> => {
|
||||||
|
if (!goalId) return null;
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
|
||||||
|
// Fetch the goal event and its zap receipts in one round-trip.
|
||||||
|
const [goalEvents, receipts] = await Promise.all([
|
||||||
|
nostr.query([{ kinds: [ZAP_GOAL_KIND], ids: [goalId] }], { signal }),
|
||||||
|
nostr.query([{ kinds: [9735], '#e': [goalId], limit: 500 }], { signal }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const goal = goalEvents[0];
|
||||||
|
if (!goal) return null;
|
||||||
|
|
||||||
|
const amountTag = goal.tags.find(([n]) => n === 'amount')?.[1];
|
||||||
|
const targetMs = amountTag ? parseInt(amountTag, 10) : 0;
|
||||||
|
const targetSats = Number.isNaN(targetMs) ? 0 : Math.floor(targetMs / 1000);
|
||||||
|
|
||||||
|
const closedAtTag = goal.tags.find(([n]) => n === 'closed_at')?.[1];
|
||||||
|
const closedAt = closedAtTag ? parseInt(closedAtTag, 10) : undefined;
|
||||||
|
|
||||||
|
const { sats: raisedSats, count } = tallyZaps(receipts, closedAt);
|
||||||
|
const percent = targetSats > 0 ? Math.round((raisedSats / targetSats) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: goal.id,
|
||||||
|
pubkey: goal.pubkey,
|
||||||
|
description: goal.content,
|
||||||
|
targetSats,
|
||||||
|
raisedSats,
|
||||||
|
contributions: count,
|
||||||
|
closedAt,
|
||||||
|
percent,
|
||||||
|
image: goal.tags.find(([n]) => n === 'image')?.[1],
|
||||||
|
summary: goal.tags.find(([n]) => n === 'summary')?.[1],
|
||||||
|
event: goal,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
45
src/hooks/useCrewmates.ts
Normal file
45
src/hooks/useCrewmates.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useCrews } from '@/hooks/useCrews';
|
||||||
|
|
||||||
|
export interface Crewmate {
|
||||||
|
pubkey: string;
|
||||||
|
/** Crews (names) the person shares with the viewer. */
|
||||||
|
sharedCrews: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people in the viewer's crews — founder + members of every crew the
|
||||||
|
* logged-in user belongs to (as founder or listed member), minus themselves.
|
||||||
|
*
|
||||||
|
* Powers the "Crew-mates" tab in Contacts: the human network the user has
|
||||||
|
* already opted into by being in shared crews.
|
||||||
|
*/
|
||||||
|
export function useCrewmates() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: crews, isLoading } = useCrews(200);
|
||||||
|
|
||||||
|
const crewmates = useMemo<Crewmate[]>(() => {
|
||||||
|
if (!user || !crews) return [];
|
||||||
|
|
||||||
|
const myCrews = crews.filter(
|
||||||
|
(c) => c.pubkey === user.pubkey || c.members.includes(user.pubkey),
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = new Map<string, Set<string>>();
|
||||||
|
for (const crew of myCrews) {
|
||||||
|
const people = [crew.pubkey, ...crew.members];
|
||||||
|
for (const pk of people) {
|
||||||
|
if (pk === user.pubkey) continue;
|
||||||
|
if (!map.has(pk)) map.set(pk, new Set());
|
||||||
|
map.get(pk)!.add(crew.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([pubkey, names]) => ({ pubkey, sharedCrews: Array.from(names) }))
|
||||||
|
.sort((a, b) => b.sharedCrews.length - a.sharedCrews.length);
|
||||||
|
}, [user, crews]);
|
||||||
|
|
||||||
|
return { crewmates, isLoading };
|
||||||
|
}
|
||||||
45
src/hooks/useCrewsForSpot.ts
Normal file
45
src/hooks/useCrewsForSpot.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import {
|
||||||
|
SKATE_CREW_KIND,
|
||||||
|
SKATE_SPOT_KIND,
|
||||||
|
parseSkateCrew,
|
||||||
|
type SkateCrew,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse link: which crews list this spot as a home spot?
|
||||||
|
*
|
||||||
|
* Crews store home spots as `a` tags (`30818:pubkey:d`). We query crews whose
|
||||||
|
* `a` tag references this spot coordinate, so a spot page can show
|
||||||
|
* "Crews that skate here".
|
||||||
|
*/
|
||||||
|
export function useCrewsForSpot(spotPubkey?: string, spotIdentifier?: string, limit = 20) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const spotCoord =
|
||||||
|
spotPubkey && spotIdentifier
|
||||||
|
? `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crews-for-spot', spotCoord, getQueryKeySuffix()],
|
||||||
|
enabled: !!spotCoord,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [SKATE_CREW_KIND], '#a': [spotCoord], limit }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const crews: SkateCrew[] = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
const crew = parseSkateCrew(ev);
|
||||||
|
// Guard: relay `#a` matching can be loose — confirm the ref is present.
|
||||||
|
if (crew && crew.spotRefs.includes(spotCoord)) crews.push(crew);
|
||||||
|
}
|
||||||
|
return crews.sort((a, b) => b.members.length - a.members.length);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/hooks/useGeolocation.ts
Normal file
55
src/hooks/useGeolocation.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { LatLng } from '@/lib/crewHelpers';
|
||||||
|
|
||||||
|
interface GeolocationState {
|
||||||
|
coords: LatLng | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around the browser Geolocation API for one-shot "where am I"
|
||||||
|
* lookups (used by the Crews "Near me" toggle). Never auto-requests — the user
|
||||||
|
* must call {@link request} so we don't prompt on page load.
|
||||||
|
*/
|
||||||
|
export function useGeolocation() {
|
||||||
|
const [state, setState] = useState<GeolocationState>({
|
||||||
|
coords: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = useCallback(() => {
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
setState((s) => ({ ...s, error: 'Geolocation is not supported on this device.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setState({
|
||||||
|
coords: { lat: pos.coords.latitude, lng: pos.coords.longitude },
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setState({
|
||||||
|
coords: null,
|
||||||
|
loading: false,
|
||||||
|
error:
|
||||||
|
err.code === err.PERMISSION_DENIED
|
||||||
|
? 'Location permission denied.'
|
||||||
|
: 'Could not get your location.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setState({ coords: null, loading: false, error: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, request, clear };
|
||||||
|
}
|
||||||
125
src/hooks/usePrivateContacts.ts
Normal file
125
src/hooks/usePrivateContacts.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private, encrypted contacts list (Armada-style).
|
||||||
|
*
|
||||||
|
* Stored as a NIP-51 Follow Set (kind 30000) with a fixed `d` identifier. The
|
||||||
|
* contact pubkeys live in the event `content`, NIP-44 encrypted to the user's
|
||||||
|
* own key — so the list never leaks the user's scene/social graph publicly,
|
||||||
|
* unlike a kind-3 follow list.
|
||||||
|
*
|
||||||
|
* This is a "who I skate with" address book that only you can read.
|
||||||
|
*/
|
||||||
|
export const PRIVATE_CONTACTS_D = 'skatespots-contacts';
|
||||||
|
const CONTACTS_KIND = 30000;
|
||||||
|
|
||||||
|
interface EncryptedContacts {
|
||||||
|
/** Array of `["p", pubkey]` style tags kept in the encrypted payload. */
|
||||||
|
tags: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch and decrypt the user's private contacts (returns hex pubkeys). */
|
||||||
|
export function usePrivateContacts() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['private-contacts', user?.pubkey],
|
||||||
|
enabled: !!user?.pubkey,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
queryFn: async (c): Promise<string[]> => {
|
||||||
|
if (!user?.pubkey || !user.signer?.nip44) return [];
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [CONTACTS_KIND], authors: [user.pubkey], '#d': [PRIVATE_CONTACTS_D], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const ev = events[0];
|
||||||
|
if (!ev) return [];
|
||||||
|
|
||||||
|
// Public p tags (fallback / merge) + decrypted private ones.
|
||||||
|
const pubkeys = new Set<string>();
|
||||||
|
for (const [t, v] of ev.tags) {
|
||||||
|
if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.content) {
|
||||||
|
try {
|
||||||
|
const decrypted = await user.signer.nip44.decrypt(user.pubkey, ev.content);
|
||||||
|
const parsed = JSON.parse(decrypted) as EncryptedContacts | string[][];
|
||||||
|
const tags = Array.isArray(parsed) ? parsed : parsed.tags;
|
||||||
|
for (const [t, v] of tags) {
|
||||||
|
if (t === 'p' && /^[0-9a-f]{64}$/i.test(v ?? '')) pubkeys.add(v.toLowerCase());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Undecryptable (different key / corrupt) — fall back to public tags.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(pubkeys);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add / remove a pubkey from the private contacts list. */
|
||||||
|
export function usePrivateContactActions() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const publishList = async (pubkeys: string[]) => {
|
||||||
|
if (!user?.signer?.nip44) {
|
||||||
|
throw new Error('Your signer does not support NIP-44 encryption.');
|
||||||
|
}
|
||||||
|
const unique = Array.from(new Set(pubkeys.filter((p) => /^[0-9a-f]{64}$/i.test(p))));
|
||||||
|
const payload = JSON.stringify(unique.map((p) => ['p', p]));
|
||||||
|
const content = await user.signer.nip44.encrypt(user.pubkey, payload);
|
||||||
|
|
||||||
|
const event = await user.signer.signEvent({
|
||||||
|
kind: CONTACTS_KIND,
|
||||||
|
content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', PRIVATE_CONTACTS_D],
|
||||||
|
['title', 'My Skate Contacts'],
|
||||||
|
['description', 'Private, encrypted list of people in my scene.'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await nostr.event(event);
|
||||||
|
return unique;
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async (pubkey: string) => {
|
||||||
|
const current =
|
||||||
|
queryClient.getQueryData<string[]>(['private-contacts', user?.pubkey]) ?? [];
|
||||||
|
return publishList([...current, pubkey]);
|
||||||
|
},
|
||||||
|
onSuccess: (list) => {
|
||||||
|
queryClient.setQueryData(['private-contacts', user?.pubkey], list);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['private-contacts', user?.pubkey] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (pubkey: string) => {
|
||||||
|
const current =
|
||||||
|
queryClient.getQueryData<string[]>(['private-contacts', user?.pubkey]) ?? [];
|
||||||
|
return publishList(current.filter((p) => p !== pubkey));
|
||||||
|
},
|
||||||
|
onSuccess: (list) => {
|
||||||
|
queryClient.setQueryData(['private-contacts', user?.pubkey], list);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['private-contacts', user?.pubkey] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addContact: add.mutateAsync,
|
||||||
|
removeContact: remove.mutateAsync,
|
||||||
|
isAdding: add.isPending,
|
||||||
|
isRemoving: remove.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
189
src/lib/crewHelpers.ts
Normal file
189
src/lib/crewHelpers.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
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: '🛹' };
|
||||||
|
}
|
||||||
|
|
@ -987,6 +987,28 @@ export const CREW_FOCUS_FROM_TAG: Record<string, CrewFocus> = {
|
||||||
'crew-focus-coaching': 'coaching',
|
'crew-focus-coaching': 'coaching',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lab crafts a crew can put out an open call for ("looking for").
|
||||||
|
* Stored as `["looking-for", <slug>]` tags on the crew event. Slugs map to the
|
||||||
|
* Lab scenes so matching providers can be surfaced.
|
||||||
|
*/
|
||||||
|
export const CREW_LOOKING_FOR_OPTIONS = [
|
||||||
|
{ slug: 'filmer', label: 'Filmer', emoji: '🎥' },
|
||||||
|
{ slug: 'photographer', label: 'Photographer', emoji: '📸' },
|
||||||
|
{ slug: 'editor', label: 'Video Editor', emoji: '✂️' },
|
||||||
|
{ slug: 'coach', label: 'Coach', emoji: '🎓' },
|
||||||
|
{ slug: 'builder', label: 'Ramp Builder', emoji: '🔨' },
|
||||||
|
{ slug: 'members', label: 'New Members', emoji: '🛹' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CrewLookingForSlug = (typeof CREW_LOOKING_FOR_OPTIONS)[number]['slug'];
|
||||||
|
|
||||||
|
export const CREW_LOOKING_FOR_LABELS: Record<string, { label: string; emoji: string }> =
|
||||||
|
Object.fromEntries(CREW_LOOKING_FOR_OPTIONS.map((o) => [o.slug, { label: o.label, emoji: o.emoji }]));
|
||||||
|
|
||||||
|
/** NIP-75 Zap Goal kind — used for crew funding goals / treasury. */
|
||||||
|
export const ZAP_GOAL_KIND = 9041;
|
||||||
|
|
||||||
/** Map a Lab service type to the crew focus that belongs in that scene. */
|
/** Map a Lab service type to the crew focus that belongs in that scene. */
|
||||||
export function crewFocusForServiceType(
|
export function crewFocusForServiceType(
|
||||||
serviceType: 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
|
serviceType: 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
|
||||||
|
|
@ -1019,6 +1041,8 @@ export interface SkateCrew {
|
||||||
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker)
|
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker)
|
||||||
spotRefs: string[]; // Home spots (kind 30818 refs)
|
spotRefs: string[]; // Home spots (kind 30818 refs)
|
||||||
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
|
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
|
||||||
|
goalRef?: string; // NIP-75 zap goal event id (crew funding goal / treasury)
|
||||||
|
lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor")
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -1560,6 +1584,12 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
.map(([, value]) => value);
|
.map(([, value]) => value);
|
||||||
const labRefs = parseLabRefs(event);
|
const labRefs = parseLabRefs(event);
|
||||||
|
// NIP-75 zap goal linked to this crew (crew treasury / funding goal).
|
||||||
|
const goalRef = event.tags.find(([name, value]) => name === 'goal' && /^[0-9a-f]{64}$/i.test(value ?? ''))?.[1];
|
||||||
|
// "Looking for" — Lab craft slugs the crew is currently hiring for.
|
||||||
|
const lookingFor = event.tags
|
||||||
|
.filter(([name, value]) => name === 'looking-for' && !!value?.trim())
|
||||||
|
.map(([, value]) => value.trim().toLowerCase());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -1584,6 +1614,8 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
admins,
|
admins,
|
||||||
spotRefs,
|
spotRefs,
|
||||||
labRefs,
|
labRefs,
|
||||||
|
goalRef,
|
||||||
|
lookingFor,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
350
src/pages/ContactsPage.tsx
Normal file
350
src/pages/ContactsPage.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useFollowing } from '@/hooks/useFollowing';
|
||||||
|
import { useConversations } from '@/hooks/useDirectMessages';
|
||||||
|
import { useCrewmates, type Crewmate } from '@/hooks/useCrewmates';
|
||||||
|
import { usePrivateContacts, usePrivateContactActions } from '@/hooks/usePrivateContacts';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
MessageCircle,
|
||||||
|
UserCheck,
|
||||||
|
Lock,
|
||||||
|
Star,
|
||||||
|
StarOff,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function ContactRow({
|
||||||
|
pubkey,
|
||||||
|
subtitle,
|
||||||
|
isSaved,
|
||||||
|
onToggleSave,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
isSaved: boolean;
|
||||||
|
onToggleSave: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const meta = author.data?.metadata;
|
||||||
|
const name = meta?.name ?? genUserName(pubkey);
|
||||||
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
// Build a minimal event-like object for ZapButton (needs pubkey).
|
||||||
|
const zapTarget = author.data?.event;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-black/40 p-3">
|
||||||
|
<Link to={`/${npub}`} className="shrink-0">
|
||||||
|
<Avatar className="h-11 w-11">
|
||||||
|
<AvatarImage src={meta?.picture} alt={name} />
|
||||||
|
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-bold">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/${npub}`} className="block truncate font-bold text-white hover:text-teal-300">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
{subtitle && <div className="truncate text-xs text-gray-500">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{zapTarget && <ZapButton target={zapTarget} className="text-xs" showCount={false} />}
|
||||||
|
<Link to="/messages" title="Message">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-teal-400">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onToggleSave}
|
||||||
|
disabled={saving}
|
||||||
|
title={isSaved ? 'Remove from private contacts' : 'Save to private contacts'}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8',
|
||||||
|
isSaved ? 'text-amber-400 hover:text-amber-300' : 'text-gray-500 hover:text-amber-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : isSaved ? (
|
||||||
|
<Star className="h-4 w-4 fill-current" />
|
||||||
|
) : (
|
||||||
|
<StarOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTab({ icon: Icon, text }: { icon: typeof Users; text: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed border-white/10 bg-transparent">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5">
|
||||||
|
<Icon className="h-7 w-7 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<p className="max-w-xs text-sm text-gray-500">{text}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Contacts - SkateSpots',
|
||||||
|
description: 'Your scene — crew-mates, people you follow and recent conversations, with private encrypted contacts.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const { data: following, isLoading: followingLoading } = useFollowing();
|
||||||
|
const { crewmates, isLoading: crewmatesLoading } = useCrewmates();
|
||||||
|
const { data: conversations, isLoading: convosLoading } = useConversations();
|
||||||
|
const { data: savedContacts } = usePrivateContacts();
|
||||||
|
const { addContact, removeContact, isAdding, isRemoving } = usePrivateContactActions();
|
||||||
|
|
||||||
|
const savedSet = useMemo(() => new Set(savedContacts ?? []), [savedContacts]);
|
||||||
|
const [pendingPubkey, setPendingPubkey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleSave = async (pubkey: string) => {
|
||||||
|
if (!user?.signer?.nip44) {
|
||||||
|
toast({
|
||||||
|
title: 'Encryption unavailable',
|
||||||
|
description: 'Your signer does not support NIP-44 encryption.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingPubkey(pubkey);
|
||||||
|
try {
|
||||||
|
if (savedSet.has(pubkey)) {
|
||||||
|
await removeContact(pubkey);
|
||||||
|
toast({ title: 'Removed from private contacts' });
|
||||||
|
} else {
|
||||||
|
await addContact(pubkey);
|
||||||
|
toast({ title: 'Saved to private contacts', description: 'Only you can see this list.' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not update contacts',
|
||||||
|
description: err instanceof Error ? err.message : 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPendingPubkey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const matchNpub = (pk: string) => !q || nip19.npubEncode(pk).toLowerCase().includes(q);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container max-w-2xl px-4 py-12">
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<Users className="h-12 w-12 text-teal-400/50" />
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 text-xl font-black">Your scene, in one place</h2>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Log in to see your crew-mates, people you follow, and recent conversations —
|
||||||
|
and keep a private, encrypted contacts list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LoginArea className="justify-center" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewmatesFiltered = crewmates.filter((c) => matchNpub(c.pubkey));
|
||||||
|
const followingFiltered = (following ?? []).filter(matchNpub);
|
||||||
|
const convoPubkeys = (conversations ?? []).map((c) => c.pubkey).filter(matchNpub);
|
||||||
|
const savedFiltered = (savedContacts ?? []).filter(matchNpub);
|
||||||
|
|
||||||
|
const renderList = (pubkeys: string[], subtitleFor?: (pk: string) => React.ReactNode) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pubkeys.map((pk) => (
|
||||||
|
<ContactRow
|
||||||
|
key={pk}
|
||||||
|
pubkey={pk}
|
||||||
|
subtitle={subtitleFor?.(pk)}
|
||||||
|
isSaved={savedSet.has(pk)}
|
||||||
|
onToggleSave={() => toggleSave(pk)}
|
||||||
|
saving={(isAdding || isRemoving) && pendingPubkey === pk}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const crewmateSubtitle = (c: Crewmate) =>
|
||||||
|
c.sharedCrews.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3 text-teal-500" />
|
||||||
|
{c.sharedCrews.slice(0, 2).join(', ')}
|
||||||
|
{c.sharedCrews.length > 2 && ` +${c.sharedCrews.length - 2}`}
|
||||||
|
</span>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container max-w-2xl px-4 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1
|
||||||
|
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
|
||||||
|
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
||||||
|
>
|
||||||
|
<Users className="h-7 w-7" /> Contacts
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">
|
||||||
|
Your scene — crew-mates, follows & conversations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Filter by npub…"
|
||||||
|
className="h-10 w-full rounded-xl border border-white/10 bg-white/5 pl-9 pr-9 text-sm text-white placeholder:text-gray-600 focus:border-teal-500/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="crewmates">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="crewmates" className="gap-1 text-xs">
|
||||||
|
<Users className="h-3.5 w-3.5" /> Crew
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="following" className="gap-1 text-xs">
|
||||||
|
<UserCheck className="h-3.5 w-3.5" /> Follows
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="dms" className="gap-1 text-xs">
|
||||||
|
<MessageCircle className="h-3.5 w-3.5" /> DMs
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="saved" className="gap-1 text-xs">
|
||||||
|
<Lock className="h-3.5 w-3.5" /> Saved
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Crew-mates */}
|
||||||
|
<TabsContent value="crewmates" className="mt-4">
|
||||||
|
{crewmatesLoading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : crewmatesFiltered.length === 0 ? (
|
||||||
|
<EmptyTab
|
||||||
|
icon={Users}
|
||||||
|
text="No crew-mates yet. Join or start a crew to build your scene network."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderList(
|
||||||
|
crewmatesFiltered.map((c) => c.pubkey),
|
||||||
|
(pk) => {
|
||||||
|
const cm = crewmatesFiltered.find((c) => c.pubkey === pk);
|
||||||
|
return cm ? crewmateSubtitle(cm) : undefined;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Following */}
|
||||||
|
<TabsContent value="following" className="mt-4">
|
||||||
|
{followingLoading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : followingFiltered.length === 0 ? (
|
||||||
|
<EmptyTab icon={UserCheck} text="You're not following anyone yet." />
|
||||||
|
) : (
|
||||||
|
renderList(followingFiltered)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Recent DMs */}
|
||||||
|
<TabsContent value="dms" className="mt-4">
|
||||||
|
{convosLoading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : convoPubkeys.length === 0 ? (
|
||||||
|
<EmptyTab
|
||||||
|
icon={MessageCircle}
|
||||||
|
text="No conversations yet. Message a skater to start one."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderList(convoPubkeys)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Saved private contacts */}
|
||||||
|
<TabsContent value="saved" className="mt-4">
|
||||||
|
<p className="mb-3 flex items-center gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[11px] text-amber-300/80">
|
||||||
|
<Lock className="h-3 w-3 shrink-0" />
|
||||||
|
This list is encrypted to your key — only you can read it. Tap the star on anyone to
|
||||||
|
add them.
|
||||||
|
</p>
|
||||||
|
{savedFiltered.length === 0 ? (
|
||||||
|
<EmptyTab
|
||||||
|
icon={Star}
|
||||||
|
text="No saved contacts yet. Star people from any tab to build your private address book."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderList(savedFiltered)
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-xl border border-white/5 p-3">
|
||||||
|
<Skeleton className="h-11 w-11 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
@ -19,7 +19,9 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/LanguageContext';
|
import { useTranslation } from '@/contexts/LanguageContext';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
|
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
|
||||||
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
|
|
@ -51,6 +53,13 @@ export default function CreateEventPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
// Optional crew host: `?crew=<pubkey>:<identifier>` — the event is hosted BY a crew.
|
||||||
|
const crewParam = searchParams.get('crew') ?? '';
|
||||||
|
const [crewHostPubkey, crewHostIdentifier] = crewParam.includes(':')
|
||||||
|
? crewParam.split(':')
|
||||||
|
: ['', ''];
|
||||||
|
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
|
|
@ -320,6 +329,13 @@ export default function CreateEventPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crew host — link the event back to the crew coordinate so it shows on
|
||||||
|
// the crew page and on the event as "Hosted by [crew]".
|
||||||
|
if (hostCrew) {
|
||||||
|
tags.push(['a', `${SKATE_CREW_KIND}:${hostCrew.pubkey}:${hostCrew.identifier}`]);
|
||||||
|
tags.push(['crew-name', hostCrew.name]);
|
||||||
|
}
|
||||||
|
|
||||||
await createEvent({
|
await createEvent({
|
||||||
kind: CALENDAR_EVENT_KIND,
|
kind: CALENDAR_EVENT_KIND,
|
||||||
content: description.trim(),
|
content: description.trim(),
|
||||||
|
|
@ -377,6 +393,22 @@ export default function CreateEventPage() {
|
||||||
{t('createEvent.subtitle')}
|
{t('createEvent.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{hostCrew && (
|
||||||
|
<Card className="mt-4 border-2 border-teal-500/40 bg-teal-500/10">
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-500/20">
|
||||||
|
<Users className="h-5 w-5 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold text-teal-300">Hosting as {hostCrew.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
This event will show on your crew page and be tagged as crew-hosted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{!hasNip05 && (
|
{!hasNip05 && (
|
||||||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
||||||
import { CrewChat } from '@/components/CrewChat';
|
import { CrewChat } from '@/components/CrewChat';
|
||||||
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
||||||
|
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
||||||
|
import { CrewLookingForCard } from '@/components/CrewLookingFor';
|
||||||
|
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
|
||||||
|
import { CrewRepBadge } from '@/components/CrewBadge';
|
||||||
|
import { useCrewEvents } from '@/hooks/useCrewEvents';
|
||||||
|
import { EventCard } from '@/components/EventCard';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||||
import {
|
import {
|
||||||
|
|
@ -137,6 +143,9 @@ export default function CrewDetailPage() {
|
||||||
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
||||||
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
||||||
|
|
||||||
|
// Crew-hosted events (jams, comps, meetups).
|
||||||
|
const { data: crewEvents } = useCrewEvents(crew?.pubkey, crew?.identifier);
|
||||||
|
|
||||||
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
|
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
|
||||||
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
|
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
|
||||||
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
|
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
|
||||||
|
|
@ -242,6 +251,7 @@ export default function CrewDetailPage() {
|
||||||
<Users className="h-3 w-3" /> Crew
|
<Users className="h-3 w-3" /> Crew
|
||||||
</Badge>
|
</Badge>
|
||||||
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
||||||
|
<CrewRepBadge crew={crew} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
|
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
|
||||||
{crew.location && (
|
{crew.location && (
|
||||||
|
|
@ -291,6 +301,13 @@ export default function CrewDetailPage() {
|
||||||
<Users className="h-3 w-3" /> Member
|
<Users className="h-3 w-3" /> Member
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{role === 'none' && (
|
||||||
|
<CrewRequestToJoin
|
||||||
|
crewName={crew.name}
|
||||||
|
founderPubkey={crew.pubkey}
|
||||||
|
admins={crew.admins}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -310,6 +327,42 @@ export default function CrewDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
||||||
|
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
||||||
|
|
||||||
|
{/* Crew treasury — transparent, zap-funded NIP-75 goal */}
|
||||||
|
{crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
|
||||||
|
|
||||||
|
{/* Crew-hosted events (jams / comps / meetups) */}
|
||||||
|
{((crewEvents && crewEvents.length > 0) || canManage) && (
|
||||||
|
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<Calendar className="h-5 w-5 text-[#ccff00]" /> Crew Events
|
||||||
|
</CardTitle>
|
||||||
|
{canManage && (
|
||||||
|
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`}>
|
||||||
|
<Button size="sm" className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||||
|
<Calendar className="h-3.5 w-3.5" /> Host a jam
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{crewEvents && crewEvents.length > 0 ? (
|
||||||
|
crewEvents.slice(0, 5).map((ev) => (
|
||||||
|
<EventCard key={ev.id} event={ev} compact />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="py-4 text-center text-sm text-gray-500">
|
||||||
|
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
|
||||||
|
crew to the events calendar and lets people zap it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Crew feed */}
|
{/* Crew feed */}
|
||||||
<Card className="bg-black/90 border-2 border-white/5">
|
<Card className="bg-black/90 border-2 border-white/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,15 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge';
|
||||||
import { useCrews } from '@/hooks/useCrews';
|
import { useCrews } from '@/hooks/useCrews';
|
||||||
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||||
|
import { useGeolocation } from '@/hooks/useGeolocation';
|
||||||
|
import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/crewHelpers';
|
||||||
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||||
|
import type { CrewActivity } from '@/lib/crewHelpers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles } from 'lucide-react';
|
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
|
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
|
||||||
{ key: 'all', label: 'All', icon: Users },
|
{ key: 'all', label: 'All', icon: Users },
|
||||||
|
|
@ -26,44 +31,78 @@ const FOCUS_BADGE_ICONS: Record<CrewFocus, typeof Users> = {
|
||||||
coaching: GraduationCap,
|
coaching: GraduationCap,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CrewCard({ crew }: { crew: SkateCrew }) {
|
function CrewCard({
|
||||||
|
crew,
|
||||||
|
activity,
|
||||||
|
distanceKm,
|
||||||
|
}: {
|
||||||
|
crew: SkateCrew;
|
||||||
|
activity?: CrewActivity;
|
||||||
|
distanceKm: number | null;
|
||||||
|
}) {
|
||||||
|
const accent = crewAccent(crew);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
||||||
className="group flex items-center gap-3 rounded-2xl border border-teal-500/20 bg-white/[0.03] p-3 transition-all hover:border-teal-500/50 hover:bg-white/[0.06] active:scale-[0.99]"
|
className={cn(
|
||||||
|
'group flex items-center gap-3 rounded-2xl border p-3 transition-all active:scale-[0.99]',
|
||||||
|
accent.border,
|
||||||
|
accent.bg,
|
||||||
|
accent.hoverBorder,
|
||||||
|
'hover:bg-white/[0.06]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Avatar className="h-14 w-14 rounded-2xl border-2 border-teal-500/30">
|
<Avatar className="h-14 w-14 rounded-2xl border-2" style={{ borderColor: `${accent.hex}55` }}>
|
||||||
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
||||||
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">
|
<AvatarFallback className="rounded-2xl font-black" style={{ color: accent.hex, background: `${accent.hex}22` }}>
|
||||||
{crew.name.slice(0, 2).toUpperCase()}
|
{crew.name.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate font-black text-white leading-tight flex items-center gap-1.5">
|
<h3 className="flex items-center gap-1.5 truncate font-black leading-tight text-white">
|
||||||
{crew.name}
|
<span className="truncate">{crew.name}</span>
|
||||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold text-teal-400">
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ color: accent.hex, background: `${accent.hex}22` }}
|
||||||
|
>
|
||||||
<Users className="h-2.5 w-2.5" /> {crew.members.length}
|
<Users className="h-2.5 w-2.5" /> {crew.members.length}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
{crew.focus !== 'mixed' && (() => {
|
|
||||||
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
return (
|
<CrewRepBadge crew={crew} />
|
||||||
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-teal-300 border border-teal-500/25">
|
<CrewActivityPulse activity={activity} />
|
||||||
<FocusIcon className="h-2.5 w-2.5" />
|
{crew.focus !== 'mixed' && (() => {
|
||||||
{CREW_FOCUS_LABELS[crew.focus]}
|
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
|
||||||
</span>
|
return (
|
||||||
);
|
<span className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
|
||||||
})()}
|
<FocusIcon className="h-2.5 w-2.5" />
|
||||||
{crew.location && (
|
{CREW_FOCUS_LABELS[crew.focus]}
|
||||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 truncate">
|
</span>
|
||||||
<MapPin className="h-3 w-3 text-teal-400 flex-shrink-0" />
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(crew.location || distanceKm !== null) && (
|
||||||
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-gray-400">
|
||||||
|
<MapPin className="h-3 w-3 flex-shrink-0" style={{ color: accent.hex }} />
|
||||||
{crew.location}
|
{crew.location}
|
||||||
|
{distanceKm !== null && (
|
||||||
|
<span className="ml-1 font-bold" style={{ color: accent.hex }}>
|
||||||
|
· {formatDistance(distanceKm)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{crew.vibes.length > 0 && (
|
{crew.vibes.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{crew.vibes.slice(0, 3).map((v) => (
|
{crew.vibes.slice(0, 3).map((v) => (
|
||||||
<span key={v} className="inline-flex items-center rounded-full border border-teal-700/50 bg-teal-900/30 px-2 py-0.5 text-[10px] font-bold text-teal-300">
|
<span
|
||||||
|
key={v}
|
||||||
|
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
|
||||||
|
>
|
||||||
{CREW_VIBE_LABELS[v] || v}
|
{CREW_VIBE_LABELS[v] || v}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -81,13 +120,28 @@ export default function CrewsPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: crews, isLoading } = useCrews(200);
|
const { data: crews, isLoading } = useCrews(200);
|
||||||
|
const { data: activityMap } = useCrewsActivity(crews);
|
||||||
|
const geo = useGeolocation();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
|
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
|
||||||
|
const [nearMe, setNearMe] = useState(false);
|
||||||
|
|
||||||
|
const userLoc: LatLng | null = geo.coords;
|
||||||
|
|
||||||
|
// Stats: crew count + distinct cities (social proof header).
|
||||||
|
const { cityCount } = useMemo(() => {
|
||||||
|
const cities = new Set(
|
||||||
|
(crews ?? [])
|
||||||
|
.map((c) => c.location?.trim().toLowerCase())
|
||||||
|
.filter((c): c is string => !!c),
|
||||||
|
);
|
||||||
|
return { cityCount: cities.size };
|
||||||
|
}, [crews]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!crews) return [];
|
if (!crews) return [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return crews.filter((c) => {
|
let list = crews.filter((c) => {
|
||||||
if (focusFilter !== 'all' && c.focus !== focusFilter) return false;
|
if (focusFilter !== 'all' && c.focus !== focusFilter) return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
|
|
@ -96,21 +150,53 @@ export default function CrewsPage() {
|
||||||
c.bio?.toLowerCase().includes(q)
|
c.bio?.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [crews, query, focusFilter]);
|
|
||||||
|
// Near-me: sort by distance ascending, crews without geo sink to the bottom.
|
||||||
|
if (nearMe && userLoc) {
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
const da = crewDistanceKm(a, userLoc);
|
||||||
|
const db = crewDistanceKm(b, userLoc);
|
||||||
|
if (da === null && db === null) return 0;
|
||||||
|
if (da === null) return 1;
|
||||||
|
if (db === null) return -1;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [crews, query, focusFilter, nearMe, userLoc]);
|
||||||
|
|
||||||
|
const handleNearMe = () => {
|
||||||
|
if (nearMe) {
|
||||||
|
setNearMe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!userLoc) geo.request();
|
||||||
|
setNearMe(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container px-4 py-6 max-w-3xl">
|
<div className="container max-w-3xl px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black uppercase text-teal-400 flex items-center gap-2" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
<h1
|
||||||
|
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
|
||||||
|
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
||||||
|
>
|
||||||
<Users className="h-7 w-7" /> Crews
|
<Users className="h-7 w-7" /> Crews
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 mt-1 text-sm">Find your people — local crews, collectives & communities</p>
|
<p className="mt-1 text-sm text-gray-400">Find your people — local crews, collectives & communities</p>
|
||||||
|
{/* Social-proof stat line */}
|
||||||
|
{!isLoading && crews && crews.length > 0 && (
|
||||||
|
<p className="mt-1 text-xs font-bold text-teal-500/80">
|
||||||
|
{crews.length} crew{crews.length === 1 ? '' : 's'}
|
||||||
|
{cityCount > 0 && ` · ${cityCount} ${cityCount === 1 ? 'city' : 'cities'}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/create-crew">
|
<Link to="/create-crew">
|
||||||
<Button className="gap-2 bg-teal-500 hover:bg-teal-600 text-black font-black uppercase text-sm">
|
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Start a Crew</span>
|
<span className="hidden sm:inline">Start a Crew</span>
|
||||||
<span className="sm:hidden">Start</span>
|
<span className="sm:hidden">Start</span>
|
||||||
|
|
@ -119,24 +205,27 @@ export default function CrewsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search crews by name or city…"
|
placeholder="Search crews by name or city…"
|
||||||
className="w-full h-10 pl-9 pr-9 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-teal-500/50 transition-all"
|
className="h-10 w-full rounded-xl border border-white/10 bg-white/5 pl-9 pr-9 text-sm text-white placeholder:text-gray-600 transition-all focus:border-teal-500/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<button onClick={() => setQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
<button
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Focus / type filter */}
|
{/* Filters row: focus chips + Near me */}
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||||
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
|
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
|
||||||
const active = focusFilter === key;
|
const active = focusFilter === key;
|
||||||
return (
|
return (
|
||||||
|
|
@ -148,7 +237,7 @@ export default function CrewsPage() {
|
||||||
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
||||||
active
|
active
|
||||||
? 'border-teal-500 bg-teal-500 text-black'
|
? 'border-teal-500 bg-teal-500 text-black'
|
||||||
: 'border-white/10 bg-white/5 text-gray-300 hover:border-teal-500/40 hover:text-teal-300'
|
: 'border-white/10 bg-white/5 text-gray-300 hover:border-teal-500/40 hover:text-teal-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
|
@ -156,8 +245,30 @@ export default function CrewsPage() {
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNearMe}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
||||||
|
nearMe
|
||||||
|
? 'border-orange-500 bg-orange-500 text-black'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-300 hover:border-orange-500/40 hover:text-orange-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{geo.loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Navigation className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Near me
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{geo.error && nearMe && (
|
||||||
|
<p className="mb-4 text-xs text-orange-400">{geo.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -172,29 +283,16 @@ export default function CrewsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
|
<EmptyState query={query} onNearMe={handleNearMe} hasLocation={!!userLoc} />
|
||||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
|
||||||
<Users className="h-8 w-8 text-gray-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-black mb-2 text-white">
|
|
||||||
{query ? 'No crews match' : 'No crews yet'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 text-sm max-w-xs mb-4">
|
|
||||||
{query ? 'Try a different search.' : 'Be the first to plant your crew on the map.'}
|
|
||||||
</p>
|
|
||||||
{!query && (
|
|
||||||
<Link to="/create-crew">
|
|
||||||
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide bg-teal-500 hover:bg-teal-600 text-black transition-all active:scale-95')}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Start a Crew
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{filtered.map((crew) => (
|
{filtered.map((crew) => (
|
||||||
<CrewCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
<CrewCard
|
||||||
|
key={`${crew.pubkey}:${crew.identifier}`}
|
||||||
|
crew={crew}
|
||||||
|
activity={activityMap?.get(crewCoordinate(crew))}
|
||||||
|
distanceKm={nearMe && userLoc ? crewDistanceKm(crew, userLoc) : null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -202,3 +300,61 @@ export default function CrewsPage() {
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyState({
|
||||||
|
query,
|
||||||
|
onNearMe,
|
||||||
|
hasLocation,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
onNearMe: () => void;
|
||||||
|
hasLocation: boolean;
|
||||||
|
}) {
|
||||||
|
if (query) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-4 py-20 text-center">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
||||||
|
<Users className="h-8 w-8 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-black text-white">No crews match</h3>
|
||||||
|
<p className="max-w-xs text-sm text-gray-500">Try a different search.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich empty state — never feels dead. Prompts to claim the local scene.
|
||||||
|
return (
|
||||||
|
<Card className="border-2 border-dashed border-teal-500/30 bg-gradient-to-br from-teal-500/[0.06] to-transparent">
|
||||||
|
<CardContent className="px-6 py-12 text-center">
|
||||||
|
<div className="mx-auto max-w-sm space-y-5">
|
||||||
|
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-3xl bg-teal-500/10 text-4xl">
|
||||||
|
🛹
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h3 className="text-xl font-black uppercase text-white">Claim your local scene</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
No crews planted here yet. Be the first — start your crew, link your home spots,
|
||||||
|
and put your scene on the map for everyone to find.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||||||
|
<Link to="/create-crew">
|
||||||
|
<Button className="w-full gap-2 bg-teal-500 font-black uppercase text-black hover:bg-teal-600 sm:w-auto">
|
||||||
|
<Plus className="h-4 w-4" /> Start a Crew
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{!hasLocation && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onNearMe}
|
||||||
|
className="w-full gap-2 border-orange-500/40 font-bold text-orange-300 hover:bg-orange-500/10 sm:w-auto"
|
||||||
|
>
|
||||||
|
<Navigation className="h-4 w-4" /> Find crews near me
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { EVENT_TYPE_LABELS } from '@/lib/types';
|
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND } from '@/lib/types';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
|
|
@ -98,6 +99,19 @@ export default function EventDetailPage() {
|
||||||
const isUpcoming = event ? event.start > Date.now() / 1000 : false;
|
const isUpcoming = event ? event.start > Date.now() / 1000 : false;
|
||||||
const isPast = event ? event.start < Date.now() / 1000 : false;
|
const isPast = event ? event.start < Date.now() / 1000 : false;
|
||||||
|
|
||||||
|
// Crew host — an `a` tag pointing at a crew coordinate (35006:pubkey:d).
|
||||||
|
const crewHost = useMemo(() => {
|
||||||
|
if (!event) return null;
|
||||||
|
const ref = event.event.tags
|
||||||
|
.filter(([t]) => t === 'a')
|
||||||
|
.map(([, v]) => v)
|
||||||
|
.find((v) => v?.startsWith(`${SKATE_CREW_KIND}:`));
|
||||||
|
if (!ref) return null;
|
||||||
|
const [, hPubkey, hId] = ref.split(':');
|
||||||
|
const name = event.event.tags.find(([t]) => t === 'crew-name')?.[1];
|
||||||
|
return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null;
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -206,6 +220,15 @@ export default function EventDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold">{event.title}</h1>
|
<h1 className="text-3xl font-bold">{event.title}</h1>
|
||||||
|
{crewHost && (
|
||||||
|
<Link
|
||||||
|
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Hosted by {crewHost.name || 'a crew'}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
import { SpotChat } from '@/components/SpotChat';
|
import { SpotChat } from '@/components/SpotChat';
|
||||||
|
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
|
||||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
|
@ -716,6 +717,11 @@ export default function SpotDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Crews that skate here — reverse link into the Crews scene */}
|
||||||
|
{!isShop && (
|
||||||
|
<CrewsForSpotCard spotPubkey={spot.pubkey} spotIdentifier={spot.identifier} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spot Chat - live local channel for this spot */}
|
{/* Spot Chat - live local channel for this spot */}
|
||||||
{!isShop && spot.geohash && (
|
{!isShop && spot.geohash && (
|
||||||
<SpotChat geohash={spot.geohash} defaultPrecision="block" />
|
<SpotChat geohash={spot.geohash} defaultPrecision="block" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue