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:
+1
-2
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user