feat(marketplace): hub with Browse / My Trades / Reputation (+ reviews)
- /marketplace is now a hub with a sticky tab bar: Browse (the gear grid), My Trades (existing flow), and Reputation — the trader's reputation score, peer recommendations, and a Reviews/trade-records list built from kind-31871 attestations (who, sold/bought, method, amount, note, link to the listing). - Nav: the old 'trades' entry becomes 'Marketplace' -> /marketplace (no longer auth-only, since browsing is public). - /trades and /listing/... deep links keep working.
This commit is contained in:
parent
4ed4e6f6fb
commit
14b3ee0bf2
4 changed files with 190 additions and 3 deletions
|
|
@ -27,7 +27,7 @@ const EditSpotPage = lazyWithRetry(() => import("./pages/EditSpotPage"));
|
||||||
const EditShopPage = lazyWithRetry(() => import("./pages/EditShopPage"));
|
const EditShopPage = lazyWithRetry(() => import("./pages/EditShopPage"));
|
||||||
const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage"));
|
const EditEventPage = lazyWithRetry(() => import("./pages/EditEventPage"));
|
||||||
const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage"));
|
const EditTourPage = lazyWithRetry(() => import("./pages/EditTourPage"));
|
||||||
const GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage"));
|
const MarketplaceHubPage = lazyWithRetry(() => import("./pages/MarketplaceHubPage"));
|
||||||
const ListingDetailPage = lazyWithRetry(() => import("./pages/ListingDetailPage"));
|
const ListingDetailPage = lazyWithRetry(() => import("./pages/ListingDetailPage"));
|
||||||
const TradesPage = lazyWithRetry(() => import("./pages/TradesPage"));
|
const TradesPage = lazyWithRetry(() => import("./pages/TradesPage"));
|
||||||
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
||||||
|
|
@ -125,7 +125,7 @@ export function AppRouter() {
|
||||||
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
|
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
|
||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
<Route path="/pyramid" element={<PyramidPage />} />
|
<Route path="/pyramid" element={<PyramidPage />} />
|
||||||
<Route path="/marketplace" element={<GearSwapPage />} />
|
<Route path="/marketplace" element={<MarketplaceHubPage />} />
|
||||||
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
|
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
|
||||||
<Route path="/trades" element={<TradesPage />} />
|
<Route path="/trades" element={<TradesPage />} />
|
||||||
<Route path="/stats" element={<CommunityStatsPage />} />
|
<Route path="/stats" element={<CommunityStatsPage />} />
|
||||||
|
|
|
||||||
116
src/components/TradeReputationPanel.tsx
Normal file
116
src/components/TradeReputationPanel.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { Star, ThumbsUp, ArrowUpRight } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||||
|
import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard';
|
||||||
|
import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTradeAttestations';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** One trade record / review row. */
|
||||||
|
function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
|
||||||
|
const role = ev.tags.find(([t]) => t === 'd')?.[1]?.split(':')[2] as TradeRole | undefined;
|
||||||
|
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? '';
|
||||||
|
const method = ev.tags.find(([t]) => t === 'method')?.[1];
|
||||||
|
const amount = ev.tags.find(([t]) => t === 'amount')?.[1];
|
||||||
|
const coord = ev.tags.find(([t]) => t === 'a')?.[1] ?? '';
|
||||||
|
const [, listingPubkey, listingId] = coord.split(':');
|
||||||
|
|
||||||
|
const author = useAuthor(counterparty);
|
||||||
|
const name = author.data?.metadata?.name ?? genUserName(counterparty);
|
||||||
|
const picture = author.data?.metadata?.picture;
|
||||||
|
|
||||||
|
const given = ev.pubkey === selfPubkey;
|
||||||
|
const date = new Date(ev.created_at * 1000).toLocaleDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-white/8 bg-white/[0.03] p-3">
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
<AvatarImage src={picture} alt={name} />
|
||||||
|
<AvatarFallback className="bg-white/10 text-[10px] font-bold text-gray-300">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||||
|
<Link to={`/${encodeURIComponent(counterparty)}`} className="font-bold text-white hover:text-[#ccff00]">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
|
||||||
|
role === 'sold' ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-sky-500/15 text-sky-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role === 'sold' ? 'Sold' : 'Bought'}
|
||||||
|
</span>
|
||||||
|
{!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ Review of you</span>}
|
||||||
|
{given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">your record</span>}
|
||||||
|
</div>
|
||||||
|
{ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500">
|
||||||
|
{method && <span>{TRADE_METHODS[method as keyof typeof TRADE_METHODS] ?? method}</span>}
|
||||||
|
{amount && <span>{Number(amount).toLocaleString()} sats</span>}
|
||||||
|
<span>{date}</span>
|
||||||
|
{listingPubkey && listingId && (
|
||||||
|
<Link to={`/listing/${listingPubkey}/${listingId}`} className="inline-flex items-center gap-0.5 text-gray-400 hover:text-[#ccff00]">
|
||||||
|
Listing <ArrowUpRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace → Reputation tab: this trader's reputation, peer recommendations,
|
||||||
|
* and their trade records / reviews (from kind-31871 attestations).
|
||||||
|
*/
|
||||||
|
export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
|
||||||
|
const { data, isLoading } = useTradeReputation(pubkey);
|
||||||
|
const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at);
|
||||||
|
const reviews = attestations.filter((a) => a.pubkey !== pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TradeReputationCard pubkey={pubkey} />
|
||||||
|
<TradeRecommendationsCard pubkey={pubkey} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
||||||
|
<Star className="h-4 w-4 text-[#ccff00]" />
|
||||||
|
Reviews & trade records
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]">
|
||||||
|
{reviews.length} from others
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-gray-500">Loading…</p>
|
||||||
|
) : attestations.length === 0 ? (
|
||||||
|
<Card className="border-dashed border-white/10">
|
||||||
|
<CardContent className="py-10 text-center">
|
||||||
|
<ThumbsUp className="mx-auto mb-3 h-8 w-8 text-gray-600" />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
No trade records yet. Complete a deal and confirm it in <b>My Trades</b> — each
|
||||||
|
confirmation becomes a review on both profiles.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attestations.map((ev) => (
|
||||||
|
<ReviewRow key={ev.id} ev={ev} selfPubkey={pubkey} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,7 @@ export const NAV_CATALOG: NavTab[] = [
|
||||||
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
|
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
|
||||||
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
|
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
|
||||||
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins },
|
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins },
|
||||||
{ id: 'trades', href: '/trades', label: 'My Trades', icon: Handshake, authOnly: true },
|
{ id: 'trades', href: '/marketplace', label: 'Marketplace', icon: Handshake },
|
||||||
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
|
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
|
||||||
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
||||||
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
|
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
|
||||||
|
|
|
||||||
71
src/pages/MarketplaceHubPage.tsx
Normal file
71
src/pages/MarketplaceHubPage.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Store, Handshake, Star } from 'lucide-react';
|
||||||
|
import GearSwapPage from '@/pages/GearSwapPage';
|
||||||
|
import TradesPage from '@/pages/TradesPage';
|
||||||
|
import { TradeReputationPanel } from '@/components/TradeReputationPanel';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'browse', label: 'Browse', icon: Store },
|
||||||
|
{ id: 'trades', label: 'My Trades', icon: Handshake },
|
||||||
|
{ id: 'reputation', label: 'Reputation', icon: Star },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]['id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace hub — one home for buying/selling: the gear grid, your trades and
|
||||||
|
* your reputation/reviews, as tabs. `/trades` and `/listing/...` deep links keep
|
||||||
|
* working; this is just the front door.
|
||||||
|
*/
|
||||||
|
export default function MarketplaceHubPage() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const [tab, setTab] = useState<TabId>('browse');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tab bar — sticky under the app header, like other toolbars. */}
|
||||||
|
<div className="sticky top-14 z-30 border-b border-white/5 bg-black/95 backdrop-blur md:top-16">
|
||||||
|
<div className="container max-w-7xl px-4">
|
||||||
|
<div className="scroll-container flex gap-1 overflow-x-auto py-2">
|
||||||
|
{TABS.map(({ id, label, icon: Icon }) => {
|
||||||
|
const active = tab === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-colors',
|
||||||
|
active ? 'bg-[#ccff00] text-black' : 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'browse' && <GearSwapPage />}
|
||||||
|
{tab === 'trades' && <TradesPage />}
|
||||||
|
{tab === 'reputation' && (
|
||||||
|
<div className="container mx-auto max-w-3xl px-4 py-8">
|
||||||
|
{user ? (
|
||||||
|
<TradeReputationPanel pubkey={user.pubkey} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-16 text-center">
|
||||||
|
<p className="text-gray-400">Log in to see your reputation and reviews.</p>
|
||||||
|
<LoginArea className="justify-center" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue