Make community stat cards clickable to view full lists

- Wire `to` links on stat cards: spots/shops/tours → map, events → /events,
  mappers → /bounties leaderboard, Lab crafts → /lab?craft=<type>, crews → /crews
- Stats already read from the canonical goskatespots relay and the map limit
  matches (2000) so numbers stay consistent with what's on the map

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 15:17:45 +07:00
parent 94a88b2ba0
commit 9cefa29640
5 changed files with 91 additions and 28 deletions

View file

@ -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<GrowthStats>({
queryKey: ['growth-stats', getQueryKeySuffix()],
queryKey: ['growth-stats', 'canonical'],
queryFn: async () => {
const signal = AbortSignal.timeout(15000);

View file

@ -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<NostrEvent[]> => {
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<string, NostrEvent>();
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,

View file

@ -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 (
<Card className="bg-black/80 border" style={{ borderColor: `${color}33` }}>
const clickable = !!to && !loading && value > 0;
const card = (
<Card
className={cn(
'bg-black/80 border transition-all h-full',
clickable && 'hover:bg-white/[0.03] hover:-translate-y-0.5 active:scale-[0.99] cursor-pointer',
)}
style={{ borderColor: `${color}33` }}
>
<CardContent className="pt-5 pb-5">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
@ -111,6 +122,11 @@ function StatCard({
</p>
)}
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
{clickable && (
<p className="text-[10px] mt-2 font-bold uppercase tracking-wide inline-flex items-center gap-1" style={{ color }}>
View all <ExternalLink className="h-2.5 w-2.5" />
</p>
)}
</div>
<div className="p-2.5 rounded-xl flex-shrink-0" style={{ background: `${color}15` }}>
<Icon className="h-6 w-6" style={{ color }} />
@ -119,6 +135,8 @@ function StatCard({
</CardContent>
</Card>
);
return clickable ? <Link to={to!} className="block h-full">{card}</Link> : card;
}
// ─── Top mapper row ──────────────────────────────────────────────────────────
@ -232,10 +250,10 @@ export default function CommunityStatsPage() {
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} />
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
</div>
</section>
@ -252,16 +270,16 @@ export default function CommunityStatsPage() {
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} />
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} />
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} />
</div>
</section>
@ -272,8 +290,8 @@ export default function CommunityStatsPage() {
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> Community
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
</div>
</section>

View file

@ -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<string[]>([]);
const [serviceType, setServiceType] = useState<ServiceType>('coaching');
const [serviceType, setServiceType] = useState<ServiceType>(initialCraft);
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [filtersOpen, setFiltersOpen] = useState(false);

View file

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