Sasquatch: full public-data coverage (all Lab crafts + shops + tours)
- findCoaches now covers EVERY Lab craft (coach, filmer, editor, photographer, builder, artist, graffiti, tattoo, trainer, musician, judge) via role param; reads skills from `t` tags and strips self-tags. - New findShops (kind 30017, t=skateshop) and findTours (kind 30823) tools. - getCommunityStats counts all crafts individually + lab_total + shops + tours, and filters spots to t=skatespot (drops foreign kind-30818 events). - Wired new tools + expanded role vocabulary into the Sasquatch brain: system prompt, intent parser, keyword detector, and both dispatch/format paths. - Privacy preserved: the public CVM data layer never queries the encrypted Stash (kind 30001) — verified no 30001/decrypt references. Personal 'my stash' stays login+Pro gated and decrypts locally with the user's own key. - Normalized useCVMClient.ts to clean UTF-8 (comment glyphs only; no code change) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
e996d96fa0
commit
407ba40a33
2 changed files with 295 additions and 42 deletions
|
|
@ -151,13 +151,13 @@ Args: city?(string), country?(string), event_type?(string), from_date?(string),
|
||||||
Example: {"tool":"find_events","args":{"city":"Bangkok"}}
|
Example: {"tool":"find_events","args":{"city":"Bangkok"}}
|
||||||
|
|
||||||
### find_coaches
|
### find_coaches
|
||||||
Args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?(string), limit?(number)
|
Args: role?("coach"|"filmer"|"editor"|"photographer"|"builder"|"artist"|"graffiti"|"tattoo"|"trainer"|"musician"|"judge"|"any"), skills?(string[]), location?(string), limit?(number)
|
||||||
- Covers all The Lab service providers: coaches, filmers, photographers, editors, and ramp/park builders
|
- Covers EVERY The Lab craft: coaches, filmers, video editors, photographers, ramp/park/DIY builders, designers/artists, graffiti artists, tattoo artists, trainers/physios, musicians/bands, and event JUDGES (Game of S.K.A.T.E., contests, tours).
|
||||||
|
- Use the matching role for a specific craft (e.g. role:"judge" for "find judges", role:"tattoo" for tattoo artists). Use role:"any" to search all crafts.
|
||||||
- location searches name, bio, city tag — use the CITY name not country name for best results
|
- location searches name, bio, city tag — use the CITY name not country name for best results
|
||||||
- "coaches in Thailand" → use location:"Bangkok" (main city)
|
- "coaches in Thailand" → use location:"Bangkok" (main city)
|
||||||
- "coaches in Bangkok" → use location:"Bangkok"
|
|
||||||
- Always fetch with limit:20 to avoid missing results
|
- Always fetch with limit:20 to avoid missing results
|
||||||
Example: {"tool":"find_coaches","args":{"location":"Bangkok","limit":20}}
|
Example: {"tool":"find_coaches","args":{"role":"judge","location":"Barcelona","limit":20}}
|
||||||
|
|
||||||
### find_gear
|
### find_gear
|
||||||
Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number)
|
Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number)
|
||||||
|
|
@ -169,6 +169,15 @@ Find skate crews (community groups). Args: city?(string), country?(string), vibe
|
||||||
- Always use limit:20+ for city/country searches
|
- Always use limit:20+ for city/country searches
|
||||||
Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}}
|
Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}}
|
||||||
|
|
||||||
|
### find_shops
|
||||||
|
Find local skate shops. Args: city?(string), country?(string), search?(string), limit?(number)
|
||||||
|
- For country queries use the main city too (Thailand → city:"Bangkok")
|
||||||
|
Example: {"tool":"find_shops","args":{"city":"Barcelona","limit":20}}
|
||||||
|
|
||||||
|
### find_tours
|
||||||
|
Find local/guided skate tours (a guide shows you the hidden spots). Args: city?(string), country?(string), search?(string), limit?(number)
|
||||||
|
Example: {"tool":"find_tours","args":{"city":"Lisbon","limit":20}}
|
||||||
|
|
||||||
### get_community_stats
|
### get_community_stats
|
||||||
Args: none
|
Args: none
|
||||||
Example: {"tool":"get_community_stats","args":{}}
|
Example: {"tool":"get_community_stats","args":{}}
|
||||||
|
|
@ -188,7 +197,7 @@ Note: this returns a rich itinerary CARD — after calling it, add a short one-l
|
||||||
## Rules
|
## Rules
|
||||||
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess
|
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
|
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). 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".)
|
3. App/protocol/how-to questions → answer directly (no tool). Wallet, Supporters, and the private Stash have NO live search tool — answer from the App facts below and point users to the right page. Spots, Events, the Lab (all crafts incl. judges), Gear, Crews, Shops and Tours all HAVE live tools — use them for "find/show" requests.
|
||||||
4. Be concise, skater-friendly
|
4. Be concise, skater-friendly
|
||||||
5. Never invent spot names, event dates, or user data
|
5. Never invent spot names, event dates, or user data
|
||||||
6. 0 results = honest "none listed yet" + encourage adding content
|
6. 0 results = honest "none listed yet" + encourage adding content
|
||||||
|
|
@ -265,6 +274,10 @@ function needsLiveData(question: string): boolean {
|
||||||
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
|
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
|
||||||
'list', 'nearby', 'close to', 'around', 'available',
|
'list', 'nearby', 'close to', 'around', 'available',
|
||||||
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around',
|
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around',
|
||||||
|
// Expanded Lab crafts + shops + tours
|
||||||
|
'photographer', 'editor', 'builder', 'artist', 'graffiti', 'tattoo',
|
||||||
|
'trainer', 'physio', 'musician', 'band', 'judge', 'judges',
|
||||||
|
'shop', 'shops', 'store', 'tour', 'tours', 'guide',
|
||||||
// Personal (Sasquatch Pro) — route to AI so it picks a get_my_* tool
|
// Personal (Sasquatch Pro) — route to AI so it picks a get_my_* tool
|
||||||
'my stash', 'my gear', 'my crew', 'my crews', 'my spots', 'my listings',
|
'my stash', 'my gear', 'my crew', 'my crews', 'my spots', 'my listings',
|
||||||
'in my stash', "what am i selling", 'what do i own', 'my inventory',
|
'in my stash', "what am i selling", 'what do i own', 'my inventory',
|
||||||
|
|
@ -283,6 +296,8 @@ type LocalIntent =
|
||||||
| { tool: 'find_events'; location: string }
|
| { tool: 'find_events'; location: string }
|
||||||
| { tool: 'find_coaches'; location: string }
|
| { tool: 'find_coaches'; location: string }
|
||||||
| { tool: 'find_crews'; location: string }
|
| { tool: 'find_crews'; location: string }
|
||||||
|
| { tool: 'find_shops'; location: string }
|
||||||
|
| { tool: 'find_tours'; location: string }
|
||||||
| { tool: 'find_gear'; search: string }
|
| { tool: 'find_gear'; search: string }
|
||||||
| { tool: 'get_community_stats' }
|
| { tool: 'get_community_stats' }
|
||||||
| { tool: 'get_weather'; city: string }
|
| { tool: 'get_weather'; city: string }
|
||||||
|
|
@ -313,13 +328,17 @@ function parseLocalIntent(question: string): LocalIntent | null {
|
||||||
return { tool: 'get_weather', city: cleanLocation(weatherMatch[1]) };
|
return { tool: 'get_weather', city: cleanLocation(weatherMatch[1]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the subject
|
// Determine the subject. Check the more specific nouns (shops, tours, the Lab
|
||||||
|
// crafts) before the generic ones so "skate shops in X" isn't mistaken for a
|
||||||
|
// spot search.
|
||||||
let tool: LocalIntent['tool'] | null = null;
|
let tool: LocalIntent['tool'] | null = null;
|
||||||
if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots';
|
if (/\bshops?\b|skate ?shops?|stores?/.test(q)) tool = 'find_shops';
|
||||||
|
else if (/\btours?\b|guides?\b/.test(q)) tool = 'find_tours';
|
||||||
else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events';
|
else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events';
|
||||||
else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?/.test(q)) tool = 'find_coaches';
|
|
||||||
else if (/\bcrews?\b/.test(q)) tool = 'find_crews';
|
else if (/\bcrews?\b/.test(q)) tool = 'find_crews';
|
||||||
else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear';
|
else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear';
|
||||||
|
else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?|builders?|artists?|graffiti|tattoo|trainers?|physios?|musicians?|bands?|judges?/.test(q)) tool = 'find_coaches';
|
||||||
|
else if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots';
|
||||||
|
|
||||||
if (!tool) return null;
|
if (!tool) return null;
|
||||||
|
|
||||||
|
|
@ -339,6 +358,8 @@ function parseLocalIntent(question: string): LocalIntent | null {
|
||||||
if (tool === 'find_spots') return { tool: 'find_spots', location };
|
if (tool === 'find_spots') return { tool: 'find_spots', location };
|
||||||
if (tool === 'find_events') return { tool: 'find_events', location };
|
if (tool === 'find_events') return { tool: 'find_events', location };
|
||||||
if (tool === 'find_coaches') return { tool: 'find_coaches', location };
|
if (tool === 'find_coaches') return { tool: 'find_coaches', location };
|
||||||
|
if (tool === 'find_shops') return { tool: 'find_shops', location };
|
||||||
|
if (tool === 'find_tours') return { tool: 'find_tours', location };
|
||||||
return { tool: 'find_crews', location };
|
return { tool: 'find_crews', location };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,13 +398,17 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
|
||||||
intent.tool === 'find_spots' ? 'spots' :
|
intent.tool === 'find_spots' ? 'spots' :
|
||||||
intent.tool === 'find_events' ? 'events' :
|
intent.tool === 'find_events' ? 'events' :
|
||||||
intent.tool === 'find_coaches' ? 'profiles' :
|
intent.tool === 'find_coaches' ? 'profiles' :
|
||||||
intent.tool === 'find_crews' ? 'crews' : 'listings';
|
intent.tool === 'find_crews' ? 'crews' :
|
||||||
|
intent.tool === 'find_shops' ? 'shops' :
|
||||||
|
intent.tool === 'find_tours' ? 'tours' : 'listings';
|
||||||
const items = (r[listKey] as Array<Record<string, unknown>>) ?? [];
|
const items = (r[listKey] as Array<Record<string, unknown>>) ?? [];
|
||||||
const label =
|
const label =
|
||||||
intent.tool === 'find_spots' ? 'spots' :
|
intent.tool === 'find_spots' ? 'spots' :
|
||||||
intent.tool === 'find_events' ? 'events' :
|
intent.tool === 'find_events' ? 'events' :
|
||||||
intent.tool === 'find_coaches' ? 'Lab pros' :
|
intent.tool === 'find_coaches' ? 'Lab pros' :
|
||||||
intent.tool === 'find_crews' ? 'crews' : 'gear listings';
|
intent.tool === 'find_crews' ? 'crews' :
|
||||||
|
intent.tool === 'find_shops' ? 'skate shops' :
|
||||||
|
intent.tool === 'find_tours' ? 'tours' : 'gear listings';
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return `No ${label} listed there yet 🦧 — be the first to add some at [skate-spots.org](https://skate-spots.org).`;
|
return `No ${label} listed there yet 🦧 — be the first to add some at [skate-spots.org](https://skate-spots.org).`;
|
||||||
|
|
@ -401,7 +426,9 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
|
||||||
intent.tool === 'find_spots' ? '🛹' :
|
intent.tool === 'find_spots' ? '🛹' :
|
||||||
intent.tool === 'find_events' ? '📅' :
|
intent.tool === 'find_events' ? '📅' :
|
||||||
intent.tool === 'find_coaches' ? '🧪' :
|
intent.tool === 'find_coaches' ? '🧪' :
|
||||||
intent.tool === 'find_crews' ? '👥' : '🛍️';
|
intent.tool === 'find_crews' ? '👥' :
|
||||||
|
intent.tool === 'find_shops' ? '🏪' :
|
||||||
|
intent.tool === 'find_tours' ? '🗺️' : '🛍️';
|
||||||
return `${emoji} Found **${items.length}** ${label}:\n${lines}`;
|
return `${emoji} Found **${items.length}** ${label}:\n${lines}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,6 +969,12 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
case 'find_crews':
|
case 'find_crews':
|
||||||
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
|
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
|
||||||
break;
|
break;
|
||||||
|
case 'find_shops':
|
||||||
|
toolResult = await cvm.findShops(toolArgs as Parameters<typeof cvm.findShops>[0]);
|
||||||
|
break;
|
||||||
|
case 'find_tours':
|
||||||
|
toolResult = await cvm.findTours(toolArgs as Parameters<typeof cvm.findTours>[0]);
|
||||||
|
break;
|
||||||
case 'get_community_stats':
|
case 'get_community_stats':
|
||||||
toolResult = await cvm.getCommunityStats();
|
toolResult = await cvm.getCommunityStats();
|
||||||
break;
|
break;
|
||||||
|
|
@ -1077,6 +1110,12 @@ Instructions:
|
||||||
case 'find_crews':
|
case 'find_crews':
|
||||||
localResult = await cvm.findCrews({ city: intent.location, limit: 20 });
|
localResult = await cvm.findCrews({ city: intent.location, limit: 20 });
|
||||||
break;
|
break;
|
||||||
|
case 'find_shops':
|
||||||
|
localResult = await cvm.findShops({ city: intent.location, limit: 20 });
|
||||||
|
break;
|
||||||
|
case 'find_tours':
|
||||||
|
localResult = await cvm.findTours({ city: intent.location, limit: 20 });
|
||||||
|
break;
|
||||||
case 'find_gear':
|
case 'find_gear':
|
||||||
localResult = await cvm.findGear({ search: intent.search, limit: 20 });
|
localResult = await cvm.findGear({ search: intent.search, limit: 20 });
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* useCVMClient
|
* useCVMClient
|
||||||
* ─────────────────────────────────────────────────────────────────────────────
|
*
|
||||||
* Queries Nostr relays directly from the browser — same logic as the CVM
|
* Queries Nostr relays directly from the browser same logic as the CVM
|
||||||
* server but without the NIP-44 encrypted round-trip (which was unreliable).
|
* server but without the NIP-44 encrypted round-trip (which was unreliable).
|
||||||
*
|
*
|
||||||
* This gives instant results with no dependency on the CVM server being up.
|
* This gives instant results with no dependency on the CVM server being up.
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
||||||
// ─── Relay list ───────────────────────────────────────────────────────────────
|
// Relay list
|
||||||
|
|
||||||
const DATA_RELAYS = [
|
const DATA_RELAYS = [
|
||||||
'wss://goskatespots.nostr1.com',
|
'wss://goskatespots.nostr1.com',
|
||||||
|
|
@ -18,7 +18,7 @@ const DATA_RELAYS = [
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Tool result types ───────────────────────────────────────────────────────
|
// Tool result types
|
||||||
|
|
||||||
export interface SpotResult {
|
export interface SpotResult {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -49,8 +49,16 @@ export interface EventResult {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Lab professional. `role` covers every public Lab craft — not just the
|
||||||
|
* original coach/filmer/editor trio.
|
||||||
|
*/
|
||||||
|
export type LabRole =
|
||||||
|
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
||||||
|
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge';
|
||||||
|
|
||||||
export interface CoachResult {
|
export interface CoachResult {
|
||||||
role: 'coach' | 'filmer' | 'editor';
|
role: LabRole;
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
|
@ -61,6 +69,32 @@ export interface CoachResult {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShopResult {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
location?: string;
|
||||||
|
geohash?: string;
|
||||||
|
image?: string;
|
||||||
|
website?: string;
|
||||||
|
owner_pubkey: string;
|
||||||
|
naddr: string;
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourResult {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
location?: string;
|
||||||
|
geohash?: string;
|
||||||
|
price?: string;
|
||||||
|
image?: string;
|
||||||
|
guide_pubkey: string;
|
||||||
|
naddr: string;
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GearResult {
|
export interface GearResult {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -95,13 +129,25 @@ export interface CommunityStats {
|
||||||
coaches: number;
|
coaches: number;
|
||||||
filmers: number;
|
filmers: number;
|
||||||
editors: number;
|
editors: number;
|
||||||
|
photographers: number;
|
||||||
|
builders: number;
|
||||||
|
artists: number;
|
||||||
|
graffiti: number;
|
||||||
|
tattoo: number;
|
||||||
|
trainers: number;
|
||||||
|
musicians: number;
|
||||||
|
judges: number;
|
||||||
|
/** Total across every Lab craft (coaches..judges). */
|
||||||
|
lab_total: number;
|
||||||
|
shops: number;
|
||||||
|
tours: number;
|
||||||
gear_listings: number;
|
gear_listings: number;
|
||||||
crews: number;
|
crews: number;
|
||||||
relays_queried: string[];
|
relays_queried: string[];
|
||||||
queried_at: string;
|
queried_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// Helpers
|
||||||
|
|
||||||
function getTag(event: { tags: string[][] }, name: string): string | undefined {
|
function getTag(event: { tags: string[][] }, name: string): string | undefined {
|
||||||
return event.tags.find((t) => t[0] === name)?.[1];
|
return event.tags.find((t) => t[0] === name)?.[1];
|
||||||
|
|
@ -131,7 +177,7 @@ function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }):
|
||||||
return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`;
|
return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── The hook ────────────────────────────────────────────────────────────────
|
// The hook
|
||||||
|
|
||||||
export function useCVMClient() {
|
export function useCVMClient() {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
|
|
@ -321,27 +367,36 @@ export function useCVMClient() {
|
||||||
|
|
||||||
const findCoaches = useCallback(
|
const findCoaches = useCallback(
|
||||||
async (params: {
|
async (params: {
|
||||||
role?: 'coach' | 'filmer' | 'editor' | 'any';
|
// Every public Lab craft is searchable. 'any' searches all of them.
|
||||||
|
role?: LabRole | 'any';
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
location?: string;
|
location?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<{ count: number; profiles: CoachResult[] }> => {
|
}): Promise<{ count: number; profiles: CoachResult[] }> => {
|
||||||
const kindMap: Record<string, number[]> = {
|
// Kind ↔ role for every Lab craft (35001..35012, minus 35006 = crews).
|
||||||
coach: [35001],
|
const ROLE_KIND: Record<LabRole, number> = {
|
||||||
filmer: [35002],
|
coach: 35001, filmer: 35002, editor: 35003, photographer: 35004,
|
||||||
editor: [35003],
|
builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009,
|
||||||
any: [35001, 35002, 35003],
|
trainer: 35010, musician: 35011, judge: 35012,
|
||||||
};
|
};
|
||||||
const kinds = kindMap[params.role ?? 'any'];
|
const KIND_ROLE: Record<number, LabRole> = Object.fromEntries(
|
||||||
|
Object.entries(ROLE_KIND).map(([role, kind]) => [kind, role as LabRole])
|
||||||
|
);
|
||||||
|
const ALL_LAB_KINDS = Object.values(ROLE_KIND);
|
||||||
|
|
||||||
|
const kinds =
|
||||||
|
params.role && params.role !== 'any' ? [ROLE_KIND[params.role]] : ALL_LAB_KINDS;
|
||||||
|
|
||||||
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
||||||
const relayGroup = nostr.group(DATA_RELAYS);
|
const relayGroup = nostr.group(DATA_RELAYS);
|
||||||
|
|
||||||
const raw = await relayGroup.query([{ kinds, limit: 200 }], { signal });
|
const raw = await relayGroup.query([{ kinds, limit: 500 }], { signal });
|
||||||
let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
|
let profiles = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
|
||||||
|
|
||||||
|
// Skills live in `t` tags (parseCraftBase) — also check legacy `skill` tags + bio.
|
||||||
if (params.skills && params.skills.length > 0) {
|
if (params.skills && params.skills.length > 0) {
|
||||||
profiles = profiles.filter((e) => {
|
profiles = profiles.filter((e) => {
|
||||||
const profileSkills = getTags(e, 'skill').map((s) => s.toLowerCase());
|
const profileSkills = [...getTags(e, 't'), ...getTags(e, 'skill')].map((s) => s.toLowerCase());
|
||||||
const bio = (e as unknown as { content: string }).content.toLowerCase();
|
const bio = (e as unknown as { content: string }).content.toLowerCase();
|
||||||
return params.skills!.some(
|
return params.skills!.some(
|
||||||
(s) =>
|
(s) =>
|
||||||
|
|
@ -361,19 +416,24 @@ export function useCVMClient() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profiles.sort((a, b) => b.created_at - a.created_at);
|
||||||
profiles = profiles.slice(0, params.limit ?? 10);
|
profiles = profiles.slice(0, params.limit ?? 10);
|
||||||
|
|
||||||
const roleLabel = (kind: number): 'coach' | 'filmer' | 'editor' =>
|
// Self-tag markers we don't want to surface as "skills".
|
||||||
kind === 35001 ? 'coach' : kind === 35002 ? 'filmer' : 'editor';
|
const SELF_TAGS = new Set([
|
||||||
|
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
|
||||||
|
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
|
||||||
|
]);
|
||||||
|
|
||||||
const result: CoachResult[] = profiles.map((e) => {
|
const result: CoachResult[] = profiles.map((e) => {
|
||||||
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
|
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
|
||||||
|
const skills = [...getTags(ev, 't'), ...getTags(ev, 'skill')].filter((t) => !SELF_TAGS.has(t));
|
||||||
return {
|
return {
|
||||||
role: roleLabel(ev.kind),
|
role: KIND_ROLE[ev.kind] ?? 'coach',
|
||||||
pubkey: ev.pubkey,
|
pubkey: ev.pubkey,
|
||||||
bio: ev.content.slice(0, 300),
|
bio: ev.content.slice(0, 300),
|
||||||
location: getTag(ev, 'location'),
|
location: getTag(ev, 'location'),
|
||||||
skills: getTags(ev, 'skill'),
|
skills,
|
||||||
experience: getTag(ev, 'experience'),
|
experience: getTag(ev, 'experience'),
|
||||||
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
|
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
|
||||||
naddr: buildNaddr(ev),
|
naddr: buildNaddr(ev),
|
||||||
|
|
@ -532,31 +592,183 @@ export function useCVMClient() {
|
||||||
[nostr]
|
[nostr]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const findShops = useCallback(
|
||||||
|
async (params: {
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{ count: number; shops: ShopResult[] }> => {
|
||||||
|
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
||||||
|
const relayGroup = nostr.group(DATA_RELAYS);
|
||||||
|
|
||||||
|
// Kind 30017 = NIP-15 stall; skate shops mark themselves with t=skateshop.
|
||||||
|
const raw = await relayGroup.query([{ kinds: [30017], limit: 500 }], { signal });
|
||||||
|
let shops = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
|
||||||
|
|
||||||
|
// Keep only skate shops (drops generic NIP-15 stalls from other apps).
|
||||||
|
shops = shops.filter((e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop'));
|
||||||
|
|
||||||
|
const locMatch = (e: typeof shops[0], q: string) =>
|
||||||
|
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(getTag(e, 'name') ?? getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(e as unknown as { content: string }).content.toLowerCase().includes(q);
|
||||||
|
|
||||||
|
if (params.city) shops = shops.filter((e) => locMatch(e, params.city!.toLowerCase()));
|
||||||
|
if (params.country) shops = shops.filter((e) => locMatch(e, params.country!.toLowerCase()));
|
||||||
|
if (params.search) {
|
||||||
|
const q = params.search.toLowerCase();
|
||||||
|
shops = shops.filter((e) => locMatch(e, q));
|
||||||
|
}
|
||||||
|
|
||||||
|
shops.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
shops = shops.slice(0, params.limit ?? 10);
|
||||||
|
|
||||||
|
const result: ShopResult[] = shops.map((e) => {
|
||||||
|
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
|
||||||
|
return {
|
||||||
|
name: getTag(ev, 'name') ?? getTag(ev, 'title') ?? 'Unnamed Shop',
|
||||||
|
description: ev.content.slice(0, 300),
|
||||||
|
location: getTag(ev, 'location'),
|
||||||
|
geohash: getTag(ev, 'g'),
|
||||||
|
image: getTag(ev, 'image') ?? getTag(ev, 'picture'),
|
||||||
|
website: getTag(ev, 'website'),
|
||||||
|
owner_pubkey: ev.pubkey,
|
||||||
|
naddr: buildNaddr(ev),
|
||||||
|
url: `https://skate-spots.org/shop/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
|
||||||
|
created_at: new Date(ev.created_at * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { count: result.length, shops: result };
|
||||||
|
},
|
||||||
|
[nostr]
|
||||||
|
);
|
||||||
|
|
||||||
|
const findTours = useCallback(
|
||||||
|
async (params: {
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{ count: number; tours: TourResult[] }> => {
|
||||||
|
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
||||||
|
const relayGroup = nostr.group(DATA_RELAYS);
|
||||||
|
|
||||||
|
// Kind 30823 = local skate tours (guides showing hidden spots).
|
||||||
|
const raw = await relayGroup.query([{ kinds: [30823], limit: 500 }], { signal });
|
||||||
|
let tours = deduplicateAddressable(raw as typeof raw & { tags: string[][] }[]);
|
||||||
|
|
||||||
|
const locMatch = (e: typeof tours[0], q: string) =>
|
||||||
|
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
|
||||||
|
(e as unknown as { content: string }).content.toLowerCase().includes(q);
|
||||||
|
|
||||||
|
if (params.city) tours = tours.filter((e) => locMatch(e, params.city!.toLowerCase()));
|
||||||
|
if (params.country) tours = tours.filter((e) => locMatch(e, params.country!.toLowerCase()));
|
||||||
|
if (params.search) {
|
||||||
|
const q = params.search.toLowerCase();
|
||||||
|
tours = tours.filter((e) => locMatch(e, q));
|
||||||
|
}
|
||||||
|
|
||||||
|
tours.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
tours = tours.slice(0, params.limit ?? 10);
|
||||||
|
|
||||||
|
const result: TourResult[] = tours.map((e) => {
|
||||||
|
const ev = e as unknown as { content: string; pubkey: string; kind: number; tags: string[][]; created_at: number };
|
||||||
|
return {
|
||||||
|
title: getTag(ev, 'title') ?? 'Untitled Tour',
|
||||||
|
description: ev.content.slice(0, 300),
|
||||||
|
location: getTag(ev, 'location'),
|
||||||
|
geohash: getTag(ev, 'g'),
|
||||||
|
price: getTag(ev, 'price'),
|
||||||
|
image: getTag(ev, 'image'),
|
||||||
|
guide_pubkey: ev.pubkey,
|
||||||
|
naddr: buildNaddr(ev),
|
||||||
|
url: `https://skate-spots.org/tour/${ev.pubkey}/${getTag(ev, 'd') ?? ''}`,
|
||||||
|
created_at: new Date(ev.created_at * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { count: result.length, tours: result };
|
||||||
|
},
|
||||||
|
[nostr]
|
||||||
|
);
|
||||||
|
|
||||||
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
|
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
|
||||||
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
const signal = AbortSignal.any([AbortSignal.timeout(10000)]);
|
||||||
const relayGroup = nostr.group(DATA_RELAYS);
|
const relayGroup = nostr.group(DATA_RELAYS);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const [spots, events, coaches, filmers, editors, gear, crews] = await Promise.all([
|
// One query per content type. Lab crafts are counted individually so the
|
||||||
relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }),
|
// stats match The Lab's per-craft tabs (coaches..judges). Everything here is
|
||||||
|
// PUBLIC data — the private encrypted Stash (kind 30001) is never touched.
|
||||||
|
const [
|
||||||
|
spots, events, gear, crews, shops, tours,
|
||||||
|
coaches, filmers, editors, photographers, builders,
|
||||||
|
artists, graffiti, tattoo, trainers, musicians, judges,
|
||||||
|
] = await Promise.all([
|
||||||
|
relayGroup.query([{ kinds: [30818], '#t': ['skatespot'], limit: 2000 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }),
|
relayGroup.query([{ kinds: [31923], limit: 500, since: now }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [30017], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [30823], limit: 500 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }),
|
relayGroup.query([{ kinds: [35001], limit: 500 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }),
|
relayGroup.query([{ kinds: [35002], limit: 500 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }),
|
relayGroup.query([{ kinds: [35003], limit: 500 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [30402], limit: 500, '#t': ['skateboarding'] }], { signal }),
|
relayGroup.query([{ kinds: [35004], limit: 500 }], { signal }),
|
||||||
relayGroup.query([{ kinds: [35006], limit: 500 }], { signal }),
|
relayGroup.query([{ kinds: [35005], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35007], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35008], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35009], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35010], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35011], limit: 500 }], { signal }),
|
||||||
|
relayGroup.query([{ kinds: [35012], limit: 500 }], { signal }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const uniq = (rows: unknown) =>
|
||||||
|
deduplicateAddressable(rows as { pubkey: string; kind: number; tags: string[][]; created_at: number }[]);
|
||||||
|
|
||||||
|
const coachesN = uniq(coaches).length;
|
||||||
|
const filmersN = uniq(filmers).length;
|
||||||
|
const editorsN = uniq(editors).length;
|
||||||
|
const photographersN = uniq(photographers).length;
|
||||||
|
const buildersN = uniq(builders).length;
|
||||||
|
const artistsN = uniq(artists).length;
|
||||||
|
const graffitiN = uniq(graffiti).length;
|
||||||
|
const tattooN = uniq(tattoo).length;
|
||||||
|
const trainersN = uniq(trainers).length;
|
||||||
|
const musiciansN = uniq(musicians).length;
|
||||||
|
const judgesN = uniq(judges).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spots: deduplicateAddressable(spots as typeof spots & { tags: string[][] }[]).filter(
|
spots: uniq(spots).filter(
|
||||||
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
|
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
|
||||||
).length,
|
).length,
|
||||||
upcoming_events: deduplicateAddressable(events as typeof events & { tags: string[][] }[]).length,
|
upcoming_events: uniq(events).length,
|
||||||
coaches: deduplicateAddressable(coaches as typeof coaches & { tags: string[][] }[]).length,
|
coaches: coachesN,
|
||||||
filmers: deduplicateAddressable(filmers as typeof filmers & { tags: string[][] }[]).length,
|
filmers: filmersN,
|
||||||
editors: deduplicateAddressable(editors as typeof editors & { tags: string[][] }[]).length,
|
editors: editorsN,
|
||||||
gear_listings: deduplicateAddressable(gear as typeof gear & { tags: string[][] }[]).length,
|
photographers: photographersN,
|
||||||
crews: deduplicateAddressable(crews as typeof crews & { tags: string[][] }[]).filter(
|
builders: buildersN,
|
||||||
|
artists: artistsN,
|
||||||
|
graffiti: graffitiN,
|
||||||
|
tattoo: tattooN,
|
||||||
|
trainers: trainersN,
|
||||||
|
musicians: musiciansN,
|
||||||
|
judges: judgesN,
|
||||||
|
lab_total:
|
||||||
|
coachesN + filmersN + editorsN + photographersN + buildersN +
|
||||||
|
artistsN + graffitiN + tattooN + trainersN + musiciansN + judgesN,
|
||||||
|
shops: uniq(shops).filter(
|
||||||
|
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skateshop')
|
||||||
|
).length,
|
||||||
|
tours: uniq(tours).length,
|
||||||
|
gear_listings: uniq(gear).length,
|
||||||
|
crews: uniq(crews).filter(
|
||||||
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')
|
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatecrew')
|
||||||
).length,
|
).length,
|
||||||
relays_queried: DATA_RELAYS,
|
relays_queried: DATA_RELAYS,
|
||||||
|
|
@ -570,6 +782,8 @@ export function useCVMClient() {
|
||||||
findCoaches,
|
findCoaches,
|
||||||
findGear,
|
findGear,
|
||||||
findCrews,
|
findCrews,
|
||||||
|
findShops,
|
||||||
|
findTours,
|
||||||
getCommunityStats,
|
getCommunityStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue