From 407ba40a333479b86deff19938a43b742c69eb15 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 27 Aug 2026 16:14:20 +0700 Subject: [PATCH] Sasquatch: full public-data coverage (all Lab crafts + shops + tours) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/SasquatchGuide.tsx | 61 +++++-- src/hooks/useCVMClient.ts | 276 ++++++++++++++++++++++++++---- 2 files changed, 295 insertions(+), 42 deletions(-) diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 0c59b19..f496abc 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -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>) ?? []; 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[0]); break; + case 'find_shops': + toolResult = await cvm.findShops(toolArgs as Parameters[0]); + break; + case 'find_tours': + toolResult = await cvm.findTours(toolArgs as Parameters[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; diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index b8820b9..94e67ad 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -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 = { - 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 = { + 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 = 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 => { 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, }; }