diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 0649cd5..8324025 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -170,6 +170,12 @@ Example: {"tool":"find_coaches","args":{"location":"Bangkok","limit":20}} Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number) Example: {"tool":"find_gear","args":{"category":"deck"}} +### find_crews +Find skate crews (community groups). Args: city?(string), country?(string), vibe?(string e.g. "street"|"transition"|"diy"), search?(string), limit?(number) +- For country queries use the main city too (Thailand → city:"Bangkok") +- Always use limit:20+ for city/country searches +Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}} + ### get_community_stats Args: none Example: {"tool":"get_community_stats","args":{}} @@ -183,7 +189,7 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}} ## Rules 1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess 2. Tool call = ONE JSON object on its own line, nothing else before or after -3. App/protocol/how-to questions → answer directly (no tool). Crews, Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. +3. App/protocol/how-to questions → answer directly (no tool). Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. (Crews DO have find_crews — use it for "find/show crews".) 4. Be concise, skater-friendly 5. Never invent spot names, event dates, or user data 6. 0 results = honest "none listed yet" + encourage adding content @@ -192,7 +198,7 @@ Example: {"tool":"get_weather","args":{"city":"Barcelona"}} - Nostr = decentralized protocol; your cryptographic keys ARE your identity — one login works everywhere, nobody can ban you - Map (the homepage) = discover spots, shops, gear, tours, events and crews worldwide. Toggle layers via the Layers control. Add a spot with the + button. - The Lab = hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Providers can be an individual OR a collective (crew/studio) — collective members confirm with their own key (green = confirmed, amber = pending). -- Crews = your map-anchored skate family (who you skate with, NOT for hire). A crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Create one at /crews → "Start a Crew". Crews reuse the same member-confirmation trust as Lab collectives. +- Crews = your map-anchored skate family (who you skate with, NOT for hire). A crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Create one at /crews → "Start a Crew". Crews reuse the same member-confirmation trust as Lab collectives. Use find_crews to search them. A crew can cross-link its Lab service listings, and you can "Zap the Crew" to split a tip across confirmed members. - Events = find/join skate events & comps (map layer + Events page) - Tours = guided local/national/world skate tours - Skate Shops = local shops on the map @@ -219,6 +225,7 @@ function needsLiveData(question: string): boolean { 'events in', 'events near', 'upcoming', 'coaches in', 'filmers in', 'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count', 'list', 'nearby', 'close to', 'around', 'available', + 'crews in', 'crews near', 'find crew', 'find a crew', 'crews around', ]; return liveKeywords.some((kw) => q.includes(kw)); } @@ -497,6 +504,9 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { case 'find_gear': toolResult = await cvm.findGear(toolArgs as Parameters[0]); break; + case 'find_crews': + toolResult = await cvm.findCrews(toolArgs as Parameters[0]); + break; case 'get_community_stats': toolResult = await cvm.getCommunityStats(); break; diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index a7211a0..b8820b9 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -74,6 +74,21 @@ export interface GearResult { created_at: string; } +export interface CrewResult { + name: string; + description: string; + location?: string; + vibes: string[]; + member_count: number; + founded?: string; + geohash?: string; + image?: string; + founder_pubkey: string; + naddr: string; + url: string; + created_at: string; +} + export interface CommunityStats { spots: number; upcoming_events: number; @@ -81,6 +96,7 @@ export interface CommunityStats { filmers: number; editors: number; gear_listings: number; + crews: number; relays_queried: string[]; queried_at: string; } @@ -440,18 +456,95 @@ export function useCVMClient() { [nostr] ); + const findCrews = useCallback( + async (params: { + city?: string; + country?: string; + vibe?: string; + search?: string; + limit?: number; + }): Promise<{ count: number; crews: CrewResult[] }> => { + const signal = AbortSignal.any([AbortSignal.timeout(10000)]); + const relayGroup = nostr.group(DATA_RELAYS); + + const raw = await relayGroup.query( + [{ kinds: [35006], limit: 500 }], + { signal } + ); + + let crews = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]); + + // Only valid crews + crews = crews.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')); + + const locMatch = (e: typeof crews[0], q: string) => + (getTag(e, 'location') ?? '').toLowerCase().includes(q) || + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q); + + if (params.city) { + const q = params.city.toLowerCase(); + crews = crews.filter((e) => locMatch(e, q)); + } + if (params.country) { + const q = params.country.toLowerCase(); + crews = crews.filter((e) => locMatch(e, q)); + } + if (params.vibe) { + const q = params.vibe.toLowerCase(); + crews = crews.filter((e) => + getTags(e, 't').some((t) => t !== 'skatecrew' && t.toLowerCase().includes(q)) + ); + } + if (params.search) { + const q = params.search.toLowerCase(); + crews = crews.filter( + (e) => + (getTag(e, 'title') ?? '').toLowerCase().includes(q) || + (e as unknown as { content: string }).content.toLowerCase().includes(q) || + getTags(e, 't').some((t) => t.toLowerCase().includes(q)) + ); + } + + crews.sort((a, b) => b.created_at - a.created_at); + crews = crews.slice(0, params.limit ?? 10); + + const result: CrewResult[] = crews.map((e) => { + const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number }; + return { + name: getTag(ev, 'title') ?? getTag(ev, 'name') ?? 'Unnamed Crew', + description: ev.content.slice(0, 300), + location: getTag(ev, 'location'), + vibes: getTags(ev, 't').filter((t) => t !== 'skatecrew'), + member_count: ev.tags.filter((t) => t[0] === 'p').length, + founded: getTag(ev, 'founded'), + geohash: getTag(ev, 'g'), + image: getTag(ev, 'image'), + founder_pubkey: ev.pubkey, + naddr: buildNaddr(ev), + url: `https://skate-spots.org/crew/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`, + created_at: new Date(ev.created_at * 1000).toISOString(), + }; + }); + + return { count: result.length, crews: result }; + }, + [nostr] + ); + const getCommunityStats = useCallback(async (): Promise => { const signal = AbortSignal.any([AbortSignal.timeout(10000)]); const relayGroup = nostr.group(DATA_RELAYS); const now = Math.floor(Date.now() / 1000); - const [spots, events, coaches, filmers, editors, gear] = await Promise.all([ + const [spots, events, coaches, filmers, editors, gear, crews] = await Promise.all([ relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }), relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }), relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }), relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }), relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }), + relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }), ]); return { @@ -463,6 +556,9 @@ export function useCVMClient() { filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length, editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length, gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length, + crews: deduplicateAddressable(crews as typeof crews & { tags: string[][] }[]).filter( + (e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew') + ).length, relays_queried: DATA_RELAYS, queried_at: new Date().toISOString(), }; @@ -473,6 +569,7 @@ export function useCVMClient() { findEvents, findCoaches, findGear, + findCrews, getCommunityStats, }; }