diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index f999611..7061b45 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -119,14 +119,14 @@ function fillMonthGaps(keys: string[]): string[] { // ─── Hook ──────────────────────────────────────────────────────────────────── export function useGrowthStats() { - // Query the SAME relay set the rest of the app uses (community relay + the - // user's relays), not just the single hardcoded community relay. Otherwise - // Lab profiles (and spots/shops/etc.) that live on a user's own relay show up - // on /lab but are missing from the community stats counts. - const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + // Community stats read from the CANONICAL community relay (goskatespots) only, + // so the numbers always match the authoritative community dataset — not a + // blend of the user's personal relays that would drift per-device. + const { queryCanonical } = useSkateRelays(); + const querySkateContent = queryCanonical; return useQuery({ - queryKey: ['growth-stats', getQueryKeySuffix()], + queryKey: ['growth-stats', 'canonical'], queryFn: async () => { const signal = AbortSignal.timeout(15000); diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index eb7de3f..eca7035 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -117,6 +117,41 @@ export function useSkateRelays() { return uniqueEvents; }; + /** + * Query the CANONICAL community relay (goskatespots) ONLY. + * + * Use this for anything that must reflect the single, authoritative community + * dataset — the map and the community stats — so what users see always matches + * the goskatespots relay exactly, regardless of which personal relays they have + * configured. Content-discovery features (feeds, individual profile lookups) + * can still use querySkateContent to blend in the user's own relays. + */ + const queryCanonical = async ( + filters: NostrFilter[], + options: { signal: AbortSignal } + ): Promise => { + try { + const relay = nostr.relay(SKATE_RELAY); + const events = await relay.query(filters, options); + + // Dedupe addressable events by pubkey+kind+d-tag (keep newest). + const eventMap = new Map(); + for (const event of events) { + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + if (dTag) { + const key = `${event.pubkey}:${event.kind}:${dTag}`; + const existing = eventMap.get(key); + if (!existing || event.created_at > existing.created_at) eventMap.set(key, event); + } else { + eventMap.set(event.id, event); + } + } + return Array.from(eventMap.values()); + } catch { + return []; + } + }; + /** * Generate a stable query key suffix based on relay configuration. * Used to invalidate cache when relay settings change. @@ -130,6 +165,7 @@ export function useSkateRelays() { getReadRelays, getWriteRelays, querySkateContent, + queryCanonical, getQueryKeySuffix, isLoggedIn, useSkateRelay, diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index f004c9c..689da38 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -8,6 +8,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useGrowthStats } from '@/hooks/useGrowthStats'; +import { cn } from '@/lib/utils'; const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; import { SKATE_RELAY } from '@/lib/types'; @@ -69,6 +70,7 @@ function StatCard({ color, loading, thisMonthDelta, + to, }: { icon: React.ComponentType<{ className?: string }>; value: number; @@ -80,9 +82,18 @@ function StatCard({ loading: boolean; /** How many new items were added this month */ thisMonthDelta?: number; + /** If set, the whole card becomes a link to this route (view all items). */ + to?: string; }) { - return ( - + const clickable = !!to && !loading && value > 0; + const card = ( +
@@ -111,6 +122,11 @@ function StatCard({

)} {sub &&

{sub}

} + {clickable && ( +

+ View all +

+ )}
@@ -119,6 +135,8 @@ function StatCard({ ); + + return clickable ? {card} : card; } // ─── Top mapper row ────────────────────────────────────────────────────────── @@ -232,10 +250,10 @@ export default function CommunityStatsPage() { Community at a Glance
- - - - + + + +
@@ -252,16 +270,16 @@ export default function CommunityStatsPage() { )}
- - - - - - - - - - + + + + + + + + + +
@@ -272,8 +290,8 @@ export default function CommunityStatsPage() { Community
- - + +
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 59bb78a..c4535e1 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useRef, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { Layout } from '@/components/Layout'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; @@ -1093,10 +1093,19 @@ export default function LabPage() { const { user } = useCurrentUser(); const { data: profiles, isLoading } = useLabProfiles(100); + // Allow deep-linking to a specific craft, e.g. /lab?craft=filming — used by + // the community stats cards so tapping a number lands on that craft's list. + const [searchParams] = useSearchParams(); + const initialCraft = (() => { + const c = searchParams.get('craft'); + const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music']; + return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching'; + })(); + const [searchQuery, setSearchQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [selectedSkills, setSelectedSkills] = useState([]); - const [serviceType, setServiceType] = useState('coaching'); + const [serviceType, setServiceType] = useState(initialCraft); const [trustFilter, setTrustFilter] = useState('all'); const [sortBy, setSortBy] = useState('recent'); const [filtersOpen, setFiltersOpen] = useState(false); diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 09ee632..d741530 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -545,8 +545,8 @@ export default function MapPage() { const { user } = useCurrentUser(); const { data: followingList } = useFollowing(user?.pubkey); const followCount = followingList?.length ?? 0; - const { data: spots, isLoading: spotsLoading } = useSkateSpots(200); - const { data: shops, isLoading: shopsLoading } = useShops(200); + const { data: spots, isLoading: spotsLoading } = useSkateSpots(2000); + const { data: shops, isLoading: shopsLoading } = useShops(500); const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200); const { data: tours, isLoading: toursLoading } = useTours(100); // Upcoming events for the (opt-in) Events map layer