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:
shakespeare.diy 2026-08-27 16:14:20 +07:00
parent e996d96fa0
commit 407ba40a33
2 changed files with 295 additions and 42 deletions

View file

@ -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;

View file

@ -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,
}; };
} }