feat(marketplace): listing detail page + shared trade/escrow actions
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full description, details, seller trust + trade reputation card, and full actions (shared) for buyers and owners (mark sold/relist/delete) - Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/ contact + escrow dialog) and use it in BOTH the grid card and the detail page so there is one implementation - useMarketplaceItem(pubkey, identifier) fetch hook - Cards link their image + title to the detail page - Smoke test covers /listing/:pubkey/:identifier Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
parent
c0aec3d912
commit
5d1a0e0fb0
6 changed files with 616 additions and 206 deletions
|
|
@ -128,6 +128,7 @@ const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: exp
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
'/',
|
'/',
|
||||||
'/marketplace',
|
'/marketplace',
|
||||||
|
'/listing/abc/def',
|
||||||
'/leaderboards',
|
'/leaderboards',
|
||||||
'/bounties',
|
'/bounties',
|
||||||
'/settings',
|
'/settings',
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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 GearSwapPage = lazyWithRetry(() => import("./pages/GearSwapPage"));
|
||||||
|
const ListingDetailPage = lazyWithRetry(() => import("./pages/ListingDetailPage"));
|
||||||
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
const SpotDetailPage = lazyWithRetry(() => import("./pages/SpotDetailPage"));
|
||||||
const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage"));
|
const ShopDetailPage = lazyWithRetry(() => import("./pages/ShopDetailPage"));
|
||||||
const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage"));
|
const EventDetailPage = lazyWithRetry(() => import("./pages/EventDetailPage"));
|
||||||
|
|
@ -119,6 +120,7 @@ export function AppRouter() {
|
||||||
<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={<GearSwapPage />} />
|
||||||
|
<Route path="/listing/:pubkey/:identifier" element={<ListingDetailPage />} />
|
||||||
<Route path="/stats" element={<CommunityStatsPage />} />
|
<Route path="/stats" element={<CommunityStatsPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/faq" element={<FAQPage />} />
|
<Route path="/faq" element={<FAQPage />} />
|
||||||
|
|
|
||||||
219
src/components/ListingTradeActions.tsx
Normal file
219
src/components/ListingTradeActions.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import {
|
||||||
|
useListingTrade,
|
||||||
|
useListingTradeRequest,
|
||||||
|
useTradeAttestation,
|
||||||
|
useTradeAttestationRequest,
|
||||||
|
} from '@/hooks/useTradeAttestations';
|
||||||
|
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
||||||
|
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
||||||
|
import { parseEscrowToken } from '@/lib/cashuEscrow';
|
||||||
|
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw } from 'lucide-react';
|
||||||
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer/seller trade + escrow actions for a marketplace listing, shared by the
|
||||||
|
* grid card and the listing detail page so there is ONE implementation.
|
||||||
|
*
|
||||||
|
* Payments aren't implemented — buyers contact the seller and settle off-app;
|
||||||
|
* these actions only record/confirm the trade for reputation (and lock escrow
|
||||||
|
* funds on native).
|
||||||
|
*/
|
||||||
|
export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const author = useAuthor(item.pubkey);
|
||||||
|
const metadata = author.data?.metadata;
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||||
|
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||||
|
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||||
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
|
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
||||||
|
const escrow = useCashuEscrow();
|
||||||
|
|
||||||
|
const [showEscrow, setShowEscrow] = useState(false);
|
||||||
|
|
||||||
|
const isOwnListing = user?.pubkey === item.pubkey;
|
||||||
|
const isSold = item.status === 'sold';
|
||||||
|
const isWTB = item.listingType === 'wtb';
|
||||||
|
|
||||||
|
const sellerNamedMe =
|
||||||
|
!!user &&
|
||||||
|
!isOwnListing &&
|
||||||
|
!!trade &&
|
||||||
|
trade.attestations.some(
|
||||||
|
(ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey),
|
||||||
|
);
|
||||||
|
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||||
|
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||||
|
|
||||||
|
const sellerEscrowPubkey =
|
||||||
|
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
||||||
|
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
||||||
|
: undefined;
|
||||||
|
const canEscrow =
|
||||||
|
escrow.available &&
|
||||||
|
!!sellerEscrowPubkey &&
|
||||||
|
!!user &&
|
||||||
|
!isOwnListing &&
|
||||||
|
!iConfirmedTrade &&
|
||||||
|
!sellerNamedMe &&
|
||||||
|
!iRequestedConfirmation;
|
||||||
|
|
||||||
|
const myEscrowToken = requestState?.requests
|
||||||
|
.find((e) => e.pubkey === user?.pubkey)
|
||||||
|
?.tags.find(([t]) => t === 'cashu_token')?.[1];
|
||||||
|
const myEscrowRefundable = (() => {
|
||||||
|
if (!myEscrowToken) return false;
|
||||||
|
try {
|
||||||
|
const info = parseEscrowToken(myEscrowToken);
|
||||||
|
return info.locked && info.refundable;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleConfirmTrade = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
|
||||||
|
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not confirm the trade',
|
||||||
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestConfirmation = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
await publishRequest({
|
||||||
|
listing: listingRef,
|
||||||
|
attestor: item.pubkey,
|
||||||
|
note: `Please confirm our trade for “${item.title}”.`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Confirmation requested',
|
||||||
|
description: 'The seller can now confirm the sale and build both reputations.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not send the request',
|
||||||
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefundEscrow = async () => {
|
||||||
|
if (!myEscrowToken) return;
|
||||||
|
try {
|
||||||
|
await escrow.redeemEscrow(myEscrowToken);
|
||||||
|
toast({ title: 'Escrow refunded', description: 'The funds are back in your Cashu wallet.' });
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not refund escrow',
|
||||||
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Buyer: confirm the trade the seller recorded (builds both reps) */}
|
||||||
|
{sellerNamedMe && !iConfirmedTrade && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmTrade}
|
||||||
|
disabled={confirmingTrade}
|
||||||
|
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||||
|
Confirm this trade
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buyer: lock funds in Cashu escrow and request confirmation (native) */}
|
||||||
|
{canEscrow && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowEscrow(true)}
|
||||||
|
className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
Escrow & request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
||||||
|
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRequestConfirmation}
|
||||||
|
disabled={requestingTrade}
|
||||||
|
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||||
|
>
|
||||||
|
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Ask seller to confirm
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||||
|
<p className="text-xs text-center text-gray-500">
|
||||||
|
Confirmation requested — waiting on the seller.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */}
|
||||||
|
{escrow.available && myEscrowRefundable && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRefundEscrow}
|
||||||
|
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Refund escrow
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`w-full gap-2 ${
|
||||||
|
isWTB
|
||||||
|
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
||||||
|
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
||||||
|
}`}
|
||||||
|
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canEscrow && sellerEscrowPubkey && (
|
||||||
|
<EscrowRequestDialog
|
||||||
|
open={showEscrow}
|
||||||
|
onOpenChange={setShowEscrow}
|
||||||
|
listing={listingRef}
|
||||||
|
sellerPubkey={sellerEscrowPubkey}
|
||||||
|
sellerName={metadata?.name ?? ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -20,14 +19,12 @@ import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
import { EscrowRequestDialog } from '@/components/EscrowRequestDialog';
|
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||||
import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
|
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
import { parseEscrowToken } from '@/lib/cashuEscrow';
|
|
||||||
import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { MessageCircle, Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2, Send, Lock } from 'lucide-react';
|
import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package, AlertTriangle, CheckCircle, RotateCcw, Trash2, Loader2 } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
|
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
|
||||||
|
|
@ -40,7 +37,6 @@ interface MarketplaceItemCardProps {
|
||||||
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
|
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
const author = useAuthor(item.pubkey);
|
const author = useAuthor(item.pubkey);
|
||||||
|
|
@ -48,8 +44,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
||||||
const avatar = metadata?.picture;
|
const avatar = metadata?.picture;
|
||||||
|
|
||||||
const npub = nip19.npubEncode(item.pubkey);
|
|
||||||
|
|
||||||
// Fetch reports for this listing
|
// Fetch reports for this listing
|
||||||
const { data: reportData } = useListingReports(item.id, item.pubkey);
|
const { data: reportData } = useListingReports(item.id, item.pubkey);
|
||||||
|
|
||||||
|
|
@ -68,13 +62,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
|
|
||||||
// Trade attestations (Attestations NIP, kind 31871) — see useTradeAttestations.
|
// Trade attestations (Attestations NIP, kind 31871) — see useTradeAttestations.
|
||||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
|
||||||
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
||||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
|
||||||
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
|
||||||
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
|
||||||
const escrow = useCashuEscrow();
|
|
||||||
const [showEscrow, setShowEscrow] = useState(false);
|
|
||||||
|
|
||||||
// Format price display
|
// Format price display
|
||||||
const priceDisplay = item.price
|
const priceDisplay = item.price
|
||||||
|
|
@ -84,6 +72,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
|
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
|
||||||
|
|
||||||
const primaryImage = item.images[0];
|
const primaryImage = item.images[0];
|
||||||
|
const detailUrl = `/listing/${item.pubkey}/${item.identifier}`;
|
||||||
const isWTB = item.listingType === 'wtb';
|
const isWTB = item.listingType === 'wtb';
|
||||||
// Expiration is stored as unix timestamp in seconds
|
// Expiration is stored as unix timestamp in seconds
|
||||||
const isExpired = item.expiration ? item.expiration < Math.floor(Date.now() / 1000) : false;
|
const isExpired = item.expiration ? item.expiration < Math.floor(Date.now() / 1000) : false;
|
||||||
|
|
@ -169,98 +158,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const isOwnListing = user?.pubkey === item.pubkey;
|
const isOwnListing = user?.pubkey === item.pubkey;
|
||||||
const isSold = item.status === 'sold';
|
const isSold = item.status === 'sold';
|
||||||
|
|
||||||
// Was the viewer named as the buyer by the seller? Then they can confirm.
|
|
||||||
const sellerNamedMe =
|
|
||||||
!!user &&
|
|
||||||
!isOwnListing &&
|
|
||||||
!!trade &&
|
|
||||||
trade.attestations.some(
|
|
||||||
(ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey),
|
|
||||||
);
|
|
||||||
const iConfirmedTrade = !!trade?.iConfirmed;
|
|
||||||
const iRequestedConfirmation = !!requestState?.iRequested;
|
|
||||||
|
|
||||||
// Seller's advertised Cashu escrow pubkey (kind 0 `cashu_escrow`), if any.
|
|
||||||
const sellerEscrowPubkey =
|
|
||||||
typeof (metadata as Record<string, unknown> | undefined)?.cashu_escrow === 'string'
|
|
||||||
? ((metadata as Record<string, unknown>).cashu_escrow as string)
|
|
||||||
: undefined;
|
|
||||||
const canEscrow =
|
|
||||||
escrow.available &&
|
|
||||||
!!sellerEscrowPubkey &&
|
|
||||||
!!user &&
|
|
||||||
!isOwnListing &&
|
|
||||||
!iConfirmedTrade &&
|
|
||||||
!sellerNamedMe &&
|
|
||||||
!iRequestedConfirmation;
|
|
||||||
|
|
||||||
// The buyer's own escrow token (from their published 31872), refundable after
|
|
||||||
// the locktime if the seller never delivered.
|
|
||||||
const myEscrowToken = requestState?.requests
|
|
||||||
.find((e) => e.pubkey === user?.pubkey)
|
|
||||||
?.tags.find(([t]) => t === 'cashu_token')?.[1];
|
|
||||||
const myEscrowRefundable = (() => {
|
|
||||||
if (!myEscrowToken) return false;
|
|
||||||
try {
|
|
||||||
const info = parseEscrowToken(myEscrowToken);
|
|
||||||
return info.locked && info.refundable;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const handleRefundEscrow = async () => {
|
|
||||||
if (!myEscrowToken) return;
|
|
||||||
try {
|
|
||||||
await escrow.redeemEscrow(myEscrowToken);
|
|
||||||
toast({
|
|
||||||
title: 'Escrow refunded',
|
|
||||||
description: 'The funds are back in your Cashu wallet.',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: 'Could not refund escrow',
|
|
||||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmTrade = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
|
|
||||||
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: 'Could not confirm the trade',
|
|
||||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestConfirmation = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
await publishRequest({
|
|
||||||
listing: listingRef,
|
|
||||||
attestor: item.pubkey,
|
|
||||||
note: `Please confirm our trade for “${item.title}”.`,
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Confirmation requested',
|
|
||||||
description: 'The seller can now confirm the sale and build both reputations.',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: 'Could not send the request',
|
|
||||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRelist = async () => {
|
const handleRelist = async () => {
|
||||||
try {
|
try {
|
||||||
await relist(item.event);
|
await relist(item.event);
|
||||||
|
|
@ -295,7 +192,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Product Image */}
|
{/* Product Image */}
|
||||||
<div className="flex-shrink-0">
|
<Link to={detailUrl} className="flex-shrink-0" aria-label={`View ${item.title}`}>
|
||||||
{primaryImage ? (
|
{primaryImage ? (
|
||||||
<div
|
<div
|
||||||
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
|
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
|
||||||
|
|
@ -313,7 +210,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
<span className="text-3xl">{isWTB ? '🔍' : '🛹'}</span>
|
<span className="text-3xl">{isWTB ? '🔍' : '🛹'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -326,13 +223,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
WANT TO BUY
|
WANT TO BUY
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<h3
|
<Link to={detailUrl}>
|
||||||
className={`font-black text-base uppercase ${
|
<h3
|
||||||
isWTB ? 'text-blue-400' : 'text-orange-400'
|
className={`font-black text-base uppercase ${
|
||||||
} leading-tight`}
|
isWTB ? 'text-blue-400' : 'text-orange-400'
|
||||||
>
|
} leading-tight`}
|
||||||
{item.title}
|
>
|
||||||
</h3>
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex items-start gap-1">
|
<div className="text-right flex items-start gap-1">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -527,85 +426,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="space-y-2">
|
<ListingTradeActions item={item} />
|
||||||
{/* NOTE: there is deliberately no "Pay with Cashu" button here.
|
|
||||||
Payments are not implemented, and a button that only shows a
|
|
||||||
"coming soon" toast is a dead end — buyers contact the seller
|
|
||||||
and arrange payment directly. */}
|
|
||||||
|
|
||||||
{/* Buyer: confirm the trade the seller recorded (builds both reps) */}
|
|
||||||
{sellerNamedMe && !iConfirmedTrade && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleConfirmTrade}
|
|
||||||
disabled={confirmingTrade}
|
|
||||||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
|
||||||
>
|
|
||||||
{confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
|
||||||
Confirm this trade
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buyer: lock funds in Cashu escrow and request confirmation (native) */}
|
|
||||||
{canEscrow && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowEscrow(true)}
|
|
||||||
className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
|
|
||||||
>
|
|
||||||
<Lock className="h-4 w-4" />
|
|
||||||
Escrow & request
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
|
||||||
{!canEscrow && user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRequestConfirmation}
|
|
||||||
disabled={requestingTrade}
|
|
||||||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
|
||||||
>
|
|
||||||
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
||||||
Ask seller to confirm
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{iRequestedConfirmation && !iConfirmedTrade && (
|
|
||||||
<p className="text-xs text-center text-gray-500">
|
|
||||||
Confirmation requested — waiting on the seller.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buyer: reclaim escrow after the locktime if the seller didn't deliver */}
|
|
||||||
{escrow.available && myEscrowRefundable && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRefundEscrow}
|
|
||||||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
Refund escrow
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contact button */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`w-full gap-2 ${
|
|
||||||
isWTB
|
|
||||||
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
|
|
||||||
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
|
|
||||||
}`}
|
|
||||||
onClick={() => navigate(`/messages/${npub}`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -620,16 +441,6 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buyer: Cashu escrow + confirmation request (native-only) */}
|
|
||||||
{canEscrow && sellerEscrowPubkey && (
|
|
||||||
<EscrowRequestDialog
|
|
||||||
open={showEscrow}
|
|
||||||
onOpenChange={setShowEscrow}
|
|
||||||
listing={listingRef}
|
|
||||||
sellerPubkey={sellerEscrowPubkey}
|
|
||||||
sellerName={displayName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
|
||||||
<DeleteEntityDialog
|
<DeleteEntityDialog
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,25 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch a single marketplace listing by author + `d` (including sold/inactive). */
|
||||||
|
export function useMarketplaceItem(pubkey?: string, identifier?: string) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['marketplace-item', pubkey, identifier, getQueryKeySuffix()],
|
||||||
|
enabled: !!pubkey && !!identifier,
|
||||||
|
staleTime: STALE_TIME,
|
||||||
|
gcTime: GC_TIME,
|
||||||
|
queryFn: async (): Promise<MarketplaceItem | null> => {
|
||||||
|
if (!pubkey || !identifier) return null;
|
||||||
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [MARKETPLACE_ITEM_KIND], authors: [pubkey], '#d': [identifier], limit: 5 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const newest = [...events].sort((a, b) => b.created_at - a.created_at)[0];
|
||||||
|
return newest ? parseMarketplaceItem(newest) : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
355
src/pages/ListingDetailPage.tsx
Normal file
355
src/pages/ListingDetailPage.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
|
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
||||||
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||||
|
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||||
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
|
import { useMarketplaceItem } from '@/hooks/useMarketplace';
|
||||||
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES } from '@/lib/types';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MessageCircle,
|
||||||
|
CheckCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
Package,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full marketplace listing page (`/listing/:pubkey/:identifier`).
|
||||||
|
*
|
||||||
|
* Gives a listing a dedicated, shareable surface: a large gallery, the full
|
||||||
|
* description, seller trust + trade reputation, and the shared trade/escrow
|
||||||
|
* actions (`ListingTradeActions`, also used by the grid card).
|
||||||
|
*/
|
||||||
|
export default function ListingDetailPage() {
|
||||||
|
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: item, isLoading } = useMarketplaceItem(pubkey, identifier);
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const metadata = author.data?.metadata;
|
||||||
|
const { data: sellerInfo } = useSellerInfo(pubkey);
|
||||||
|
const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
|
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const [showMarkSold, setShowMarkSold] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: item ? `${item.title} — SkateSpots Marketplace` : 'Listing — SkateSpots',
|
||||||
|
description: item?.summary || item?.description || 'A listing on the SkateSpots marketplace.',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container mx-auto max-w-4xl px-4 py-8 space-y-4">
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
<Skeleton className="aspect-video w-full rounded-2xl" />
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
|
||||||
|
<Package className="mx-auto h-10 w-10 text-gray-600" />
|
||||||
|
<h1 className="text-xl font-black uppercase text-white">Listing not found</h1>
|
||||||
|
<p className="text-sm text-gray-400">It may have been deleted, or the link is wrong.</p>
|
||||||
|
<Button asChild className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||||
|
<Link to="/marketplace">Back to Gear Swap</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwnListing = user?.pubkey === item.pubkey;
|
||||||
|
const isWTB = item.listingType === 'wtb';
|
||||||
|
const isSold = item.status === 'sold';
|
||||||
|
const priceDisplay = item.price
|
||||||
|
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
|
||||||
|
: formatPrice(undefined, item.priceType);
|
||||||
|
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||||
|
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||||
|
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : 'Unknown');
|
||||||
|
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
||||||
|
|
||||||
|
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
|
||||||
|
|
||||||
|
const handleRelist = async () => {
|
||||||
|
try {
|
||||||
|
await relist(item.event);
|
||||||
|
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not update',
|
||||||
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container mx-auto max-w-5xl px-4 py-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/marketplace')}
|
||||||
|
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Gear Swap
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
{/* Main column */}
|
||||||
|
<div className="min-w-0 space-y-6">
|
||||||
|
{/* Gallery */}
|
||||||
|
{item.images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{item.images.map((url, i) => (
|
||||||
|
<button
|
||||||
|
key={url + i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
className={cn(
|
||||||
|
'group relative overflow-hidden rounded-2xl border border-white/10 bg-black/40',
|
||||||
|
i === 0 && item.images.length > 1 ? 'sm:col-span-2 aspect-video' : 'aspect-square',
|
||||||
|
)}
|
||||||
|
aria-label={`View photo ${i + 1}`}
|
||||||
|
>
|
||||||
|
<ImageWithFallback
|
||||||
|
src={url}
|
||||||
|
alt={`${item.title} — photo ${i + 1}`}
|
||||||
|
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title + price */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
{isWTB && (
|
||||||
|
<Badge className="border-blue-500/50 bg-blue-500/20 text-blue-400">
|
||||||
|
<Package className="mr-1 h-3 w-3" /> WANT TO BUY
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSold && (
|
||||||
|
<Badge variant="secondary" className="bg-gray-700 text-gray-300">
|
||||||
|
SOLD
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.status && item.status !== 'active' && !isSold && (
|
||||||
|
<Badge variant="secondary" className="bg-yellow-900/50 text-yellow-400">
|
||||||
|
{item.status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-black uppercase leading-tight text-white sm:text-3xl">
|
||||||
|
{item.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-orange-400">{priceDisplay}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta badges */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{conditionInfo && (
|
||||||
|
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||||
|
{conditionInfo.emoji} {conditionInfo.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.size && (
|
||||||
|
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||||
|
{item.size}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{categoryLabel && (
|
||||||
|
<Badge variant="outline" className="border-gray-600 bg-gray-800/50">
|
||||||
|
<Tag className="mr-1 h-3 w-3" /> {categoryLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{item.description && (
|
||||||
|
<Card className="bg-black/90 border-white/10">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm uppercase tracking-wide text-gray-400">
|
||||||
|
Description
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm text-gray-200">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<Card className="bg-black/90 border-white/10">
|
||||||
|
<CardContent className="grid grid-cols-1 gap-3 p-4 text-sm sm:grid-cols-2">
|
||||||
|
{item.location && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-300">
|
||||||
|
<MapPin className="h-4 w-4 text-gray-500" /> {item.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.availability && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-300">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-500" /> Usually here {item.availability}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Calendar className="h-4 w-4" /> Listed{' '}
|
||||||
|
{new Date(item.createdAt * 1000).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="space-y-6">
|
||||||
|
{/* Seller */}
|
||||||
|
<Card className="bg-black/90 border-white/10">
|
||||||
|
<CardContent className="space-y-3 p-4">
|
||||||
|
<Link to={`/${npub}`} className="flex items-center gap-3 group">
|
||||||
|
<div className="h-10 w-10 overflow-hidden rounded-full border border-white/10 bg-white/5">
|
||||||
|
{metadata?.picture ? (
|
||||||
|
<ImageWithFallback
|
||||||
|
src={metadata.picture}
|
||||||
|
alt={displayName}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-bold text-white group-hover:text-[#ccff00]">
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{npub}</p>
|
||||||
|
</div>
|
||||||
|
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
|
||||||
|
</Link>
|
||||||
|
{sellerInfo && sellerInfo.listingCount > 0 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{sellerInfo.listingCount} listing{sellerInfo.listingCount === 1 ? '' : 's'} ·{' '}
|
||||||
|
{sellerInfo.accountAge}d old
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isOwnListing ? (
|
||||||
|
<Card className="bg-black/90 border-white/10">
|
||||||
|
<CardContent className="space-y-2 p-4">
|
||||||
|
{isSold ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRelist}
|
||||||
|
disabled={isUpdatingStatus}
|
||||||
|
className="w-full gap-2 border-white/15"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" /> Relist as active
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMarkSold(true)}
|
||||||
|
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" /> Mark as sold
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDelete(true)}
|
||||||
|
className="w-full gap-2 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" /> Delete listing
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="bg-black/90 border-white/10">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<ListingTradeActions item={item} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="mt-2 w-full gap-2 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Link to={`/messages/${npub}`}>
|
||||||
|
<MessageCircle className="h-4 w-4" /> Message {displayName}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seller trade reputation */}
|
||||||
|
<TradeReputationCard pubkey={item.pubkey} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageLightbox
|
||||||
|
images={lightboxImages}
|
||||||
|
index={lightboxIndex}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOwnListing && (
|
||||||
|
<MarkSoldDialog
|
||||||
|
open={showMarkSold}
|
||||||
|
onOpenChange={setShowMarkSold}
|
||||||
|
event={item.event}
|
||||||
|
listing={{ pubkey: item.pubkey, identifier: item.identifier }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwnListing && (
|
||||||
|
<DeleteEntityDialog
|
||||||
|
event={item.event}
|
||||||
|
entityLabel="listing"
|
||||||
|
entityName={item.title}
|
||||||
|
open={showDelete}
|
||||||
|
onOpenChange={setShowDelete}
|
||||||
|
redirectTo="/marketplace"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue