diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 96e61a95..515061d2 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/hooks/useBadgeFeed.ts b/src/hooks/useBadgeFeed.ts new file mode 100644 index 00000000..16708f36 --- /dev/null +++ b/src/hooks/useBadgeFeed.ts @@ -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(); + 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, + }); +} diff --git a/src/hooks/useFeedSettings.ts b/src/hooks/useFeedSettings.ts index 19ba0dcd..2868ed8a 100644 --- a/src/hooks/useFeedSettings.ts +++ b/src/hooks/useFeedSettings.ts @@ -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 = { 'emoji-packs': 'emojis', + 'badges': 'shop', }; /** diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index ea5784e5..0d44b4e1 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -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 }, ]; diff --git a/src/pages/BadgesFeedPage.tsx b/src/pages/BadgesFeedPage.tsx deleted file mode 100644 index bdd05329..00000000 --- a/src/pages/BadgesFeedPage.tsx +++ /dev/null @@ -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 ( - Feed.' - : undefined - } - /> - ); -} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 374f65c7..e07cd7b5 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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 = { 'treasures': TreasuresPage, 'world': WorldPage, 'books': BooksPage, - 'badges': BadgesFeedPage, + 'shop': ShopPage, + 'achievements': AchievementsPage, }; /** Sidebar items that use KindFeedPage and need extra kind definitions. */ diff --git a/src/pages/ShopPage.tsx b/src/pages/ShopPage.tsx index 0e05ce72..8f20faf0 100644 --- a/src/pages/ShopPage.tsx +++ b/src/pages/ShopPage.tsx @@ -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 ( -
- {/* Page header */} -
-
- -
-
-

Badge Shop

-

Collect badges to show off on your profile

-
+
+ {/* Quick actions */} +
+ +
{/* Category filter pills */} @@ -171,7 +185,6 @@ export function ShopPage() { return ( - {/* Badge image */}
{heroImage ? ( - {/* Name */}

{badge.name}

- {/* Description */} {badge.description && (

{badge.description}

)} - {/* Price / Owned + Supply */}
{owned ? ( @@ -232,3 +242,167 @@ export function ShopPage() {
); } + +// ─── NoteCard Skeleton ───────────────────────────────────────────────────────── + +function NoteCardSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+ ); +} + +// ─── Page ────────────────────────────────────────────────────────────────────── + +export function ShopPage() { + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + const [activeTab, setActiveTab] = useState(() => { + 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(); + 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 ( +
+ {/* Page header */} +
+ + + +
+ +

Badge Shop

+
+
+ + {/* Tabs */} +
+ handleSetTab('shop')} /> + handleSetTab('follows')} disabled={!user} /> + handleSetTab('global')} /> +
+ + {/* Tab content */} + {activeTab === 'shop' ? ( + + ) : ( + + {showSkeleton ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : feedEvents.length > 0 ? ( +
+ {feedEvents.map((event) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} +
+ ) : ( + handleSetTab('global') : undefined} + /> + )} +
+ )} +
+ ); +}