refactor: merge community badge feed into Badge Shop as tabs

Badge Shop (/shop) now has 3 tabs following the ThemesPage pattern:
- Shop: admin badge grid with categories, search, prices (unchanged)
- Follows: NoteCard feed of badge events from followed users
- Global: NoteCard feed of all badge events

The standalone BadgesFeedPage is deleted — its content now lives as
the Follows/Global tabs inside the Shop page.

- Add useBadgeFeed hook (modeled on useThemeFeed) for infinite scroll
- Remove 'badges' sidebar item, keep 'shop' and 'achievements'
- Add 'badges' → 'shop' sidebar ID migration so existing users
  who had Badges in their sidebar seamlessly get Badge Shop instead
- Redirect /badges → /shop for backward compatibility
- Add quick-action links to Create Badge and My Badges on Shop tab
This commit is contained in:
Mary Kate Fain
2026-03-20 23:16:55 -05:00
parent e0a5702374
commit f0efbf683d
7 changed files with 266 additions and 54 deletions
+1 -2
View File
@@ -14,7 +14,6 @@ import { getExtraKindDef } from "./lib/extraKinds";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { AIChatPage } from "./pages/AIChatPage";
import { BadgeManagePage } from "./pages/BadgeManagePage";
import { BadgesFeedPage } from "./pages/BadgesFeedPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { BooksPage } from "./pages/BooksPage";
import { ContentPage } from "./pages/ContentPage";
@@ -208,7 +207,7 @@ export function AppRouter() {
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesFeedPage />} />
<Route path="/badges" element={<Navigate to="/shop" replace />} />
<Route path="/badges/create" element={<BadgeCreatePage />} />
<Route path="/badges/manage" element={<BadgeManagePage />} />
<Route path="/achievements" element={<AchievementsPage />} />
+64
View File
@@ -0,0 +1,64 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useFollowList } from './useFollowActions';
import { BADGE_DEFINITION_KIND, BADGE_PROFILE_KIND } from '@/lib/badgeUtils';
const PAGE_SIZE = 20;
/** Hook to fetch a feed of badge-related Nostr events with infinite scroll and follows/global tabs. */
export function useBadgeFeed(tab: 'follows' | 'global' = 'global') {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const followList = followData?.pubkeys;
// For follows tab, wait until follow list is loaded
const followsReady = tab !== 'follows' || (!!user && followList !== undefined);
return useInfiniteQuery({
queryKey: ['badge-feed', tab, user?.pubkey ?? ''],
queryFn: async ({ pageParam }) => {
const signal = AbortSignal.timeout(5000);
const baseUntil = pageParam as number | undefined;
// For follows tab, build the authors list
let authors: string[] | undefined;
if (tab === 'follows' && user && followList) {
authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey];
}
const shared = {
limit: PAGE_SIZE,
...(baseUntil ? { until: baseUntil } : {}),
...(authors ? { authors } : {}),
};
// Query both badge kinds in a single request
const events = await nostr.query(
[{ kinds: [BADGE_DEFINITION_KIND, BADGE_PROFILE_KIND], ...shared }],
{ signal },
);
// Deduplicate and sort
const seen = new Set<string>();
return events
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
})
.sort((a, b) => b.created_at - a.created_at)
.slice(0, PAGE_SIZE);
},
getNextPageParam: (lastPage: NostrEvent[]) => {
if (lastPage.length === 0) return undefined;
return lastPage[lastPage.length - 1].created_at - 1;
},
initialPageParam: undefined as number | undefined,
enabled: followsReady,
staleTime: 2 * 60_000,
});
}
+1
View File
@@ -15,6 +15,7 @@ const DEFAULT_SIDEBAR_ORDER = SIDEBAR_ITEMS
/** Map of legacy sidebar item IDs to their current replacements. */
const SIDEBAR_ID_MIGRATIONS: Record<string, string> = {
'emoji-packs': 'emojis',
'badges': 'shop',
};
/**
+1 -3
View File
@@ -1,5 +1,4 @@
import {
Award,
BarChart3,
Bell,
Blocks,
@@ -141,9 +140,8 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ id: "treasures", label: "Treasures", path: "/treasures", icon: ChestIcon },
{ id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus },
{ id: "development", label: "Development", path: "/development", icon: Code },
{ id: "achievements", label: "Achievements", path: "/achievements", icon: Trophy },
{ id: "shop", label: "Badge Shop", path: "/shop", icon: ShoppingBag },
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
{ id: "achievements", label: "Achievements", path: "/achievements", icon: Trophy },
{ id: "world", label: "World", path: "/world", icon: Earth },
];
-26
View File
@@ -1,26 +0,0 @@
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { getExtraKindDef, getPageKinds } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { KindFeedPage } from './KindFeedPage';
/** Find the Badges definition from EXTRA_KINDS. */
const badgesDef = getExtraKindDef('badges')!;
export function BadgesFeedPage() {
const { feedSettings } = useFeedSettings();
const kinds = getPageKinds(badgesDef, feedSettings);
return (
<KindFeedPage
kind={kinds}
title={badgesDef.label}
icon={sidebarItemIcon('badges', 'size-5')}
kindDef={badgesDef}
emptyMessage={
kinds.length === 0
? 'All badge types are disabled. Enable badge definitions or profile badges in Settings > Feed.'
: undefined
}
/>
);
}
+4 -2
View File
@@ -19,7 +19,8 @@ import { TreasuresPage } from './TreasuresPage';
import { WorldPage } from './WorldPage';
import { BooksPage } from './BooksPage';
import { KindFeedPage } from './KindFeedPage';
import { BadgesFeedPage } from './BadgesFeedPage';
import { ShopPage } from './ShopPage';
import { AchievementsPage } from './AchievementsPage';
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
@@ -43,7 +44,8 @@ const PAGE_COMPONENTS: Record<string, React.ComponentType> = {
'treasures': TreasuresPage,
'world': WorldPage,
'books': BooksPage,
'badges': BadgesFeedPage,
'shop': ShopPage,
'achievements': AchievementsPage,
};
/** Sidebar items that use KindFeedPage and need extra kind definitions. */
+195 -21
View File
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { ShoppingBag, Search, Check, Zap, Sparkles } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useQueryClient } from '@tanstack/react-query';
import { ShoppingBag, Search, Check, Zap, Sparkles, Loader2, ArrowLeft, Plus, Settings2 } from 'lucide-react';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
@@ -12,15 +14,26 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { TabButton } from '@/components/TabButton';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileBadges } from '@/hooks/useProfileBadges';
import { useBadgeFeed } from '@/hooks/useBadgeFeed';
import { SHOP_CATEGORIES } from '@/lib/shopCategories';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { BADGE_DEFINITION_KIND, getBadgePrice, getBadgeSupply, getBadgeCategory, isShopBadge } from '@/lib/badgeUtils';
export function ShopPage() {
// ─── Types ─────────────────────────────────────────────────────────────────────
type ShopTab = 'shop' | 'follows' | 'global';
// ─── Shop Tab Content ──────────────────────────────────────────────────────────
function ShopContent() {
const { config } = useAppContext();
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -29,8 +42,6 @@ export function ShopPage() {
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchText, setSearchText] = useState('');
useSeoMeta({ title: 'Badge Shop' });
const adminPubkey = config.nip85StatsPubkey;
const { data: rawBadges, isLoading } = useQuery({
@@ -47,13 +58,11 @@ export function ShopPage() {
staleTime: 2 * 60_000,
});
// Set of owned badge aTags for O(1) lookup
const ownedATags = useMemo(
() => new Set(ownedBadgeRefs.map((r) => r.aTag)),
[ownedBadgeRefs],
);
// Parse, filter by category and search
const filteredBadges = useMemo(() => {
if (!rawBadges) return [];
@@ -78,16 +87,21 @@ export function ShopPage() {
}, [rawBadges, selectedCategory, searchText]);
return (
<div className="container max-w-5xl mx-auto px-4 py-8 space-y-6">
{/* Page header */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
<ShoppingBag className="size-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Badge Shop</h1>
<p className="text-sm text-muted-foreground">Collect badges to show off on your profile</p>
</div>
<div className="px-4 py-5 space-y-5">
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-1.5" asChild>
<Link to="/badges/create">
<Plus className="size-3.5" />
Create Badge
</Link>
</Button>
<Button variant="outline" size="sm" className="gap-1.5" asChild>
<Link to="/badges/manage">
<Settings2 className="size-3.5" />
My Badges
</Link>
</Button>
</div>
{/* Category filter pills */}
@@ -171,7 +185,6 @@ export function ShopPage() {
return (
<Link key={aTag} to={`/${naddr}`} className="group">
<Card className="overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5">
{/* Badge image */}
<div className="aspect-square overflow-hidden bg-secondary/20">
{heroImage ? (
<img
@@ -189,17 +202,14 @@ export function ShopPage() {
</div>
<CardContent className="p-3 space-y-1.5">
{/* Name */}
<p className="font-semibold text-sm leading-snug truncate">{badge.name}</p>
{/* Description */}
{badge.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{badge.description}
</p>
)}
{/* Price / Owned + Supply */}
<div className="flex items-center justify-between pt-1">
{owned ? (
<Badge variant="secondary" className="gap-1 text-xs font-medium">
@@ -232,3 +242,167 @@ export function ShopPage() {
</div>
);
}
// ─── NoteCard Skeleton ─────────────────────────────────────────────────────────
function NoteCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-6 mt-3 -ml-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
);
}
// ─── Page ──────────────────────────────────────────────────────────────────────
export function ShopPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<ShopTab>(() => {
try {
const stored = sessionStorage.getItem('ditto:feed-tab:shop');
if (stored === 'shop' || stored === 'follows' || stored === 'global') return stored;
} catch { /* ignore */ }
return 'shop';
});
const handleSetTab = useCallback((tab: ShopTab) => {
setActiveTab(tab);
try { sessionStorage.setItem('ditto:feed-tab:shop', tab); } catch { /* ignore */ }
}, []);
useSeoMeta({
title: `Badge Shop | ${config.appName}`,
description: 'Collect badges, discover new ones, and show them off on your profile',
});
// Feed query for follows/global tabs
const feedTab = activeTab === 'follows' ? 'follows' : 'global';
const feedQuery = useBadgeFeed(feedTab);
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery;
// Auto-fetch page 2 for smoother scrolling
useEffect(() => {
if (activeTab !== 'shop' && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [activeTab, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
threshold: 0,
rootMargin: '400px',
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten and deduplicate feed events
const feedEvents = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return (rawData.pages as NostrEvent[][])
.flat()
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
});
}, [rawData?.pages]);
const handleRefresh = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ['badge-feed', feedTab] });
}, [queryClient, feedTab]);
const showSkeleton = activeTab !== 'shop' && (isPending || (isLoading && !rawData));
return (
<main className="pb-16 sidebar:pb-0">
{/* Page header */}
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2 flex-1 min-w-0">
<ShoppingBag className="size-5" />
<h1 className="text-xl font-bold">Badge Shop</h1>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
<TabButton label="Shop" active={activeTab === 'shop'} onClick={() => handleSetTab('shop')} />
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetTab('follows')} disabled={!user} />
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetTab('global')} />
</div>
{/* Tab content */}
{activeTab === 'shop' ? (
<ShopContent />
) : (
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
) : feedEvents.length > 0 ? (
<div>
{feedEvents.map((event) => (
<NoteCard key={event.id} event={event} />
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
) : (
<FeedEmptyState
message={
activeTab === 'follows'
? 'No badge activity from people you follow yet.'
: 'No badge activity found. Be the first to create one!'
}
onSwitchToGlobal={activeTab === 'follows' ? () => handleSetTab('global') : undefined}
/>
)}
</PullToRefresh>
)}
</main>
);
}