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"}}
|
||||
|
||||
### find_coaches
|
||||
Args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?(string), limit?(number)
|
||||
- Covers all The Lab service providers: coaches, filmers, photographers, editors, and ramp/park builders
|
||||
Args: role?("coach"|"filmer"|"editor"|"photographer"|"builder"|"artist"|"graffiti"|"tattoo"|"trainer"|"musician"|"judge"|"any"), skills?(string[]), location?(string), limit?(number)
|
||||
- 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
|
||||
- "coaches in Thailand" → use location:"Bangkok" (main city)
|
||||
- "coaches in Bangkok" → use location:"Bangkok"
|
||||
- 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
|
||||
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
|
||||
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
|
||||
Args: none
|
||||
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
|
||||
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). 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
|
||||
5. Never invent spot names, event dates, or user data
|
||||
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',
|
||||
'list', 'nearby', 'close to', 'around', 'available',
|
||||
'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
|
||||
'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',
|
||||
|
|
@ -283,6 +296,8 @@ type LocalIntent =
|
|||
| { tool: 'find_events'; location: string }
|
||||
| { tool: 'find_coaches'; location: string }
|
||||
| { tool: 'find_crews'; location: string }
|
||||
| { tool: 'find_shops'; location: string }
|
||||
| { tool: 'find_tours'; location: string }
|
||||
| { tool: 'find_gear'; search: string }
|
||||
| { tool: 'get_community_stats' }
|
||||
| { tool: 'get_weather'; city: string }
|
||||
|
|
@ -313,13 +328,17 @@ function parseLocalIntent(question: string): LocalIntent | null {
|
|||
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;
|
||||
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 (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?/.test(q)) tool = 'find_coaches';
|
||||
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 (/\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;
|
||||
|
||||
|
|
@ -339,6 +358,8 @@ function parseLocalIntent(question: string): LocalIntent | null {
|
|||
if (tool === 'find_spots') return { tool: 'find_spots', location };
|
||||
if (tool === 'find_events') return { tool: 'find_events', 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 };
|
||||
}
|
||||
|
||||
|
|
@ -377,13 +398,17 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
|
|||
intent.tool === 'find_spots' ? 'spots' :
|
||||
intent.tool === 'find_events' ? 'events' :
|
||||
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 label =
|
||||
intent.tool === 'find_spots' ? 'spots' :
|
||||
intent.tool === 'find_events' ? 'events' :
|
||||
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) {
|
||||
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_events' ? '📅' :
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
|
@ -942,6 +969,12 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
case 'find_crews':
|
||||
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
|
||||
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':
|
||||
toolResult = await cvm.getCommunityStats();
|
||||
break;
|
||||
|
|
@ -1077,6 +1110,12 @@ Instructions:
|
|||
case 'find_crews':
|
||||
localResult = await cvm.findCrews({ city: intent.location, limit: 20 });
|
||||
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':
|
||||
localResult = await cvm.findGear({ search: intent.search, limit: 20 });
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* 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).
|
||||
*
|
||||
* This gives instant results with no dependency on the CVM server being up.
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
// ─── Relay list ───────────────────────────────────────────────────────────────
|
||||
// Relay list
|
||||
|
||||
const DATA_RELAYS = [
|
||||
'wss://goskatespots.nostr1.com',
|
||||
|
|
@ -18,7 +18,7 @@ const DATA_RELAYS = [
|
|||
'wss://relay.damus.io',
|
||||
];
|
||||
|
||||
// ─── Tool result types ───────────────────────────────────────────────────────
|
||||
// Tool result types
|
||||
|
||||
export interface SpotResult {
|
||||
name: string;
|
||||
|
|
@ -49,8 +49,16 @@ export interface EventResult {
|
|||
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 {
|
||||
role: 'coach' | 'filmer' | 'editor';
|
||||
role: LabRole;
|
||||
pubkey: string;
|
||||
bio: string;
|
||||
location?: string;
|
||||
|
|
@ -61,6 +69,32 @@ export interface CoachResult {
|
|||
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 {
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -95,13 +129,25 @@ export interface CommunityStats {
|
|||
coaches: number;
|
||||
filmers: 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;
|
||||
crews: number;
|
||||
relays_queried: string[];
|
||||
queried_at: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
|
||||
function getTag(event: { tags: string[][] }, name: string): string | undefined {
|
||||
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') ?? ''}`;
|
||||
}
|
||||
|
||||
// ─── The hook ────────────────────────────────────────────────────────────────
|
||||
// The hook
|
||||
|
||||
export function useCVMClient() {
|
||||
const { nostr } = useNostr();
|
||||
|
|
@ -321,27 +367,36 @@ export function useCVMClient() {
|
|||
|
||||
const findCoaches = useCallback(
|
||||
async (params: {
|
||||
role?: 'coach' | 'filmer' | 'editor' | 'any';
|
||||
// Every public Lab craft is searchable. 'any' searches all of them.
|
||||
role?: LabRole | 'any';
|
||||
skills?: string[];
|
||||
location?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ count: number; profiles: CoachResult[] }> => {
|
||||
const kindMap: Record<string, number[]> = {
|
||||
coach: [35001],
|
||||
filmer: [35002],
|
||||
editor: [35003],
|
||||
any: [35001, 35002, 35003],
|
||||
// Kind ↔ role for every Lab craft (35001..35012, minus 35006 = crews).
|
||||
const ROLE_KIND: Record<LabRole, number> = {
|
||||
coach: 35001, filmer: 35002, editor: 35003, photographer: 35004,
|
||||
builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009,
|
||||
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 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[][] }[]);
|
||||
|
||||
// Skills live in `t` tags (parseCraftBase) — also check legacy `skill` tags + bio.
|
||||
if (params.skills && params.skills.length > 0) {
|
||||
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();
|
||||
return params.skills!.some(
|
||||
(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);
|
||||
|
||||
const roleLabel = (kind: number): 'coach' | 'filmer' | 'editor' =>
|
||||
kind === 35001 ? 'coach' : kind === 35002 ? 'filmer' : 'editor';
|
||||
// Self-tag markers we don't want to surface as "skills".
|
||||
const SELF_TAGS = new Set([
|
||||
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
|
||||
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
|
||||
]);
|
||||
|
||||
const result: CoachResult[] = profiles.map((e) => {
|
||||
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 {
|
||||
role: roleLabel(ev.kind),
|
||||
role: KIND_ROLE[ev.kind] ?? 'coach',
|
||||
pubkey: ev.pubkey,
|
||||
bio: ev.content.slice(0, 300),
|
||||
location: getTag(ev, 'location'),
|
||||
skills: getTags(ev, 'skill'),
|
||||
skills,
|
||||
experience: getTag(ev, 'experience'),
|
||||
lightning_address: getTag(ev, 'lud16') ?? getTag(ev, 'contact'),
|
||||
naddr: buildNaddr(ev),
|
||||
|
|
@ -532,31 +592,183 @@ export function useCVMClient() {
|
|||
[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 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, crews] = await Promise.all([
|
||||
relayGroup.query([{ kinds: [30818], limit: 2000 }], { signal }),
|
||||
// One query per content type. Lab crafts are counted individually so the
|
||||
// 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: [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: [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 }),
|
||||
relayGroup.query([{ kinds: [35004], 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 {
|
||||
spots: deduplicateAddressable(spots as typeof spots & { tags: string[][] }[]).filter(
|
||||
spots: uniq(spots).filter(
|
||||
(e) => e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
|
||||
).length,
|
||||
upcoming_events: deduplicateAddressable(events as typeof events & { tags: string[][] }[]).length,
|
||||
coaches: deduplicateAddressable(coaches as typeof coaches & { tags: string[][] }[]).length,
|
||||
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(
|
||||
upcoming_events: uniq(events).length,
|
||||
coaches: coachesN,
|
||||
filmers: filmersN,
|
||||
editors: editorsN,
|
||||
photographers: photographersN,
|
||||
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')
|
||||
).length,
|
||||
relays_queried: DATA_RELAYS,
|
||||
|
|
@ -570,6 +782,8 @@ export function useCVMClient() {
|
|||
findCoaches,
|
||||
findGear,
|
||||
findCrews,
|
||||
findShops,
|
||||
findTours,
|
||||
getCommunityStats,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue