This commit is contained in:
2026-10-20 13:00:08 -04:00
parent a1de67ea14
commit bcd8630a8e
18 changed files with 483 additions and 172 deletions
+43 -27
View File
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Clock3, Gavel, LoaderCircle, RefreshCw, ShoppingBag, Tag } from 'lucide-react';
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE, formatUsd, parseUsd, marketMessage, type MarketAction, type MarketInfo, type MarketListing,
type MarketOrder, type MarketActivity } from '../shared/market.ts';
import { ADDITIONAL_MARKET_FEE, formatUsd, marketMessage, type MarketAction, type MarketInfo,
type MarketListing, type MarketOrder } from '../shared/market.ts';
import { atomicXmrString, formatXmr, parseAtomicXmr, parseXmr } from '../shared/xmr-amount.ts';
import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type MarketActivityV3,
type MarketCommandV3, type MarketListingRecord, type MarketOrderRecord } from '../shared/market-v3.ts';
import { sign, type Wallet } from './wallet.ts';
import { imageCredit } from './Card.tsx';
import './market.css';
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void };
type Page = { listings: MarketListing[]; next: number | null; info: MarketInfo };
type Page = { listings: MarketListingRecord[]; next: number | null; info: MarketInfo };
const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`;
const feeDisclosure = (marketBps: number) => `Hosting peer fee: ${marketBps / 100}% plus a hardcoded 1% project author fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Author fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`;
async function api<T>(body?: unknown, offset = 0): Promise<T> {
@@ -21,11 +24,21 @@ async function api<T>(body?: unknown, offset = 0): Promise<T> {
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Marketplace request failed.');
return data;
}
async function command<T>(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise<T> {
async function commandV3<T>(wallet: Wallet, info: MarketInfo, action: MarketActionV3): Promise<T> {
const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address,
version: 3 as const, publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return api<T>({ ...envelope, signature: await sign(wallet, marketMessageV3(envelope as Omit<MarketCommandV3, 'signature'>)) });
}
async function commandV2<T>(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise<T> {
const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address,
publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return api<T>({ ...envelope, signature: await sign(wallet, marketMessage(envelope)) });
}
const orderPrice = (order: MarketOrderRecord) => isXmrOrder(order)
? `${formatXmr(parseAtomicXmr(order.priceAtomicXmr))} XMR`
: `${formatUsd(order.priceCents)} USD (legacy)`;
const listingPriceLegacy = (listing: Extract<MarketListingRecord, { priceCents: number }>) =>
`${formatUsd(listing.highestBid?.priceCents ?? listing.priceCents)} USD (legacy)`;
function deadline(time: number, now: number) {
if (time <= now) return 'Closed';
const minutes = Math.ceil((time - now) / 60000);
@@ -34,17 +47,17 @@ function deadline(time: number, now: number) {
export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
const [page, setPage] = useState<Page>();
const [activity, setActivity] = useState<MarketActivity>({ listings: [], orders: [] });
const [activity, setActivity] = useState<MarketActivityV3>({ listings: [], orders: [] });
const [tab, setTab] = useState<'browse' | 'sell' | 'orders'>('browse');
const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all');
const [cardHash, setCardHash] = useState('');
const [kind, setKind] = useState<'fixed' | 'auction'>('fixed');
const [price, setPrice] = useState('5.00');
const [price, setPrice] = useState('0.100000000000');
const [hours, setHours] = useState('24');
const [bids, setBids] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [review, setReview] = useState<{ action: MarketAction; text: string }>();
const [review, setReview] = useState<{ action: MarketAction | MarketActionV3; text: string; legacy?: boolean }>();
const [now, setNow] = useState(Date.now());
const version = useRef(0);
const inFlight = useRef(false);
@@ -57,10 +70,10 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
const refresh = useCallback(async () => {
const current = version.current;
const data = await api<Page>();
if (data.info.network !== NETWORK || data.info.paymentMode !== 'mock' ||
if (data.info.network !== NETWORK || data.info.marketVersion !== 3 || data.info.paymentMode !== 'mock' ||
data.info.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || data.info.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
(snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible marketplace network, payment mode, or fee policy.');
const mine = wallet ? await command<MarketActivity>(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] };
const mine = wallet ? await commandV3<MarketActivityV3>(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] };
if (current !== version.current) return;
setPage(data); setActivity(mine); setNow(Date.now());
}, [wallet, snapshot?.chainId]);
@@ -72,7 +85,7 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000);
return () => { active = false; version.current++; clearInterval(timer); };
}, [refresh, rpcAvailable]);
const prepare = (action: MarketAction, text: string) => { setError(''); setReview({ action, text }); };
const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => { setError(''); setReview({ action, text, legacy }); };
const submit = async () => {
if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return;
const currentVersion = version.current;
@@ -80,7 +93,8 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
try {
const action = review.action.type === 'deliver' ? { ...review.action, transfer: { ...review.action.transfer,
signature: await sign(wallet, nftTransferMessage(review.action.transfer)) } } : review.action;
await command(wallet, page.info, action);
if (review.legacy) await commandV2(wallet, page.info, action as MarketAction);
else await commandV3(wallet, page.info, action as MarketActionV3);
if (currentVersion !== version.current) return;
const type = review.action.type;
setReview(undefined);
@@ -97,21 +111,21 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
const create = () => {
if (!snapshot || !page || !owned.some(b => b.tokenId === cardHash)) { setError('Select an eligible card first.'); return; }
try {
const priceCents = parseUsd(price), duration = Number(hours);
const priceAtomicXmr = atomicXmrString(parseXmr(price)), duration = Number(hours);
if (kind === 'auction' && (!Number.isFinite(duration) || duration < 1 / 60 || duration > 720)) throw new Error('Choose an auction duration from one minute to 30 days.');
prepare({ type: 'list', cardHash, sequence: snapshot.transfers.filter(tx => transactionCardIds(tx).includes(cardHash)).length,
kind, priceCents, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps },
`List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatUsd(priceCents)}. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`);
kind, priceAtomicXmr, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps },
`List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); }
};
const deliver = async (o: MarketOrder) => {
const deliver = async (o: MarketOrderRecord) => {
if (!wallet || !page) return;
try {
const tx = { network: NETWORK, chainId: page.info.chainId, cardHash: o.cardHash,
from: wallet.address, to: o.buyer, sequence: o.sequence };
const transfer = { ...tx, publicKey: wallet.publicKey, signature: '' }; // Sign only after explicit review confirmation.
prepare({ type: 'deliver', orderId: o.id, transfer },
`Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`);
`Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Agreed sale price: ${orderPrice(o)}. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`, !isXmrOrder(o));
} catch { setError('Could not prepare delivery signature.'); }
};
const loadMore = async () => {
@@ -124,34 +138,36 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
};
const disabled = busy || !wallet || !rpcAvailable || !page;
return <section className="marketplace">
<header className="market-hero"><div><div className="section-eyebrow">THIS PEER’S COLLECTOR EXCHANGE</div><h1>Your next find.<br/><span>Your kind of market.</span></h1><p>Buy a fixed-price card or join a timed auction. Every peer can host its own market: a broad exchange or a community centered on a collection. Prices are in USD; cryptocurrency checkout is designed for Monero (XMR), currently simulated.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>PEER-HOSTED · COMMUNITY-DRIVEN</span></div></header>
<header className="market-hero"><div><div className="section-eyebrow">THIS PEER’S COLLECTOR EXCHANGE</div><h1>Your next find.<br/><span>Your kind of market.</span></h1><p>Buy a fixed-price card or join a timed auction. Prices are listed directly in XMR atomic units (12 decimal places). Every peer can host its own market; listings and orders remain local to that host.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>PEER-HOSTED · COMMUNITY-DRIVEN</span></div></header>
<p className="muted">Sales support the hosting peer’s operator through a configurable fee and the project author through a hardcoded 1% fee. Both are deducted from seller proceeds. Collection-specific curation tools and peer-hosted custom TCGs are planned, not available yet.</p>
<div className="market-simulation" role="note"><ShieldNote/><div><strong>Mock payments only · never send real XMR</strong><p>Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.</p></div><span className="market-mode">SANDBOX</span></div>
<div className="market-toolbar"><div className="market-tabs" role="group" aria-label="Marketplace section">{(['browse','sell','orders'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':'Your activity'}</button>)}</div><button className="text-button" onClick={()=>{setError('');void refresh().catch(e=>setError(String(e.message)));}} disabled={busy||!rpcAvailable}><RefreshCw size={14}/>Refresh</button></div>
{page&&<><div className="market-meta"><span>Host <strong>{page.info.feeBps/100}%</strong> + author <strong>1%</strong> · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from seller</span><span>Delivery <strong>{page.info.deliveryConfirmations} confirmations</strong> before mock payout</span><span>Simulation rate <strong>${page.info.mockUsdPerXmr}/XMR</strong> · not a live quote</span></div><p className="market-fee-recipient">Hardcoded project author fee recipient · simulated payout only, not a checkout address: <span>{ADDITIONAL_MARKET_FEE.address}</span></p></>}
{page&&<><div className="market-meta"><span>Host <strong>{page.info.feeBps/100}%</strong> + author <strong>1%</strong> · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from seller</span><span>Delivery <strong>{page.info.deliveryConfirmations} confirmations</strong> before mock payout</span><span>New listings and orders: <strong>XMR-native</strong></span></div><p className="market-fee-recipient">Hardcoded project author fee recipient · simulated payout only, not a checkout address: <span>{ADDITIONAL_MARKET_FEE.address}</span></p></>}
{!rpcAvailable&&<p className="market-error" role="alert">Reconnect to the node before using the marketplace.</p>}
{!wallet&&<p className="muted">Open your local wallet to list cards, bid, and check out.</p>}
{error&&<p className="market-error" role="alert">{error}</p>}
{review&&<section className="market-review" aria-label="Review marketplace signature" ref={reviewPanel} tabIndex={-1}><div className="section-eyebrow">REVIEW BEFORE SIGNING</div><p>{review.text}</p><div className="market-actions"><button className="button dark" onClick={submit} disabled={!!disabled}>{busy?<LoaderCircle size={16} className="spin"/>:<Check size={16}/>}Sign and confirm</button><button className="button outline" onClick={()=>setReview(undefined)} disabled={busy}>Back</button></div></section>}
{tab==='browse'&&<><div className="market-filter" role="group" aria-label="Listing type">{(['all','fixed','auction'] as const).map(f=><button key={f} onClick={()=>setFilter(f)} className={filter===f?'selected':''}>{f==='all'?'All discoveries':f==='fixed'?'Buy now':'Auctions'}</button>)}</div><div className="market-grid">{page?.listings.filter(l=>filter==='all'||l.kind===filter).map(l=>{
const ended=l.kind==='auction'&&now>=l.endsAt!, mine=l.seller===wallet?.address;
const salePrice=l.highestBid?.priceCents??l.priceCents;
const legacy=!isXmrListing(l);
const saleAtomic=isXmrListing(l)?(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr):undefined;
const salePrice=legacy?(l.highestBid?.priceCents??l.priceCents):undefined;
const article=snapshot?.blocks.find(b=>b.tokenId===l.cardHash)?.article;
return <article className="market-card" key={l.id}><div className="market-art"><img src={l.image} alt="" loading="lazy"/><span>{l.kind==='auction'?<Gavel size={12}/>:<Tag size={12}/>} {l.kind==='auction'?'Timed auction':'Fixed price'}</span></div><div className="market-card-body"><small>BY {short(l.seller)} {mine?'· YOU':''}</small><h2>{l.title}</h2><div className="market-price"><strong>{formatUsd(salePrice)}</strong><span>{l.kind==='fixed'?'XMR at checkout':l.highestBid?'Highest bid':'Starting bid'}</span></div>{l.kind==='auction'&&<p className="market-countdown"><Clock3 size={13}/>{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}</p>}
{l.status==='reserved'?<p className="market-reserved">Reserved · awaiting settlement</p>:mine?<button className="button outline full" disabled={!!disabled||!!l.highestBid} onClick={()=>prepare({type:'cancel',listingId:l.id},`Cancel the listing for ${l.title}?`)}>Cancel listing</button>:l.kind==='fixed'||ended?<button className="button dark full" disabled={!!disabled||(ended&&l.highestBid?.bidder!==wallet?.address)} onClick={()=>prepare({type:'buy',listingId:l.id,priceCents:salePrice,feeBps:l.feeBps},`Reserve ${l.title} for ${formatUsd(salePrice)} with simulated XMR checkout. ${feeDisclosure(l.feeBps)}. Complete mock payment within 30 minutes ${ended?'of auction closing':'of reservation'}.`)}><ShoppingBag size={14}/>{ended?'Winner checkout':'Buy now'}</button>:<div className="market-bid"><label className="sr-only" htmlFor={`bid-${l.id}`}>Your USD bid for {l.title}</label><input id={`bid-${l.id}`} inputMode="decimal" value={bids[l.id]??''} placeholder={formatUsd(l.highestBid?salePrice+1:salePrice)} onChange={e=>setBids(v=>({...v,[l.id]:e.target.value}))}/><button className="button dark" disabled={!!disabled} onClick={()=>{try{const priceCents=parseUsd(bids[l.id]??'');prepare({type:'bid',listingId:l.id,priceCents},`Bid ${formatUsd(priceCents)} on ${l.title}. If you win, check out within 30 minutes of closing. Bids are signed but not funded; all payment is simulated.`);}catch(e){setError(e instanceof Error?e.message:'Invalid bid');}}}><Gavel size={14}/>Bid</button></div>}
return <article className="market-card" key={l.id}><div className="market-art"><img src={l.image} alt="" loading="lazy"/><span>{l.kind==='auction'?<Gavel size={12}/>:<Tag size={12}/>} {l.kind==='auction'?'Timed auction':'Fixed price'}</span></div><div className="market-card-body"><small>BY {short(l.seller)} {mine?'· YOU':''} {legacy&&<strong>· USD LEGACY</strong>}</small><h2>{l.title}</h2><div className="market-price"><strong>{legacy?formatUsd(salePrice!):`${formatXmr(parseAtomicXmr(saleAtomic!))} XMR`}</strong><span>{legacy?'Legacy USD terms':l.kind==='fixed'?'Native-XMR price':l.highestBid?'Highest bid':'Starting bid'}</span></div>{legacy&&l.status==='open'&&!mine&&<p className="market-reserved">Legacy USD listing is read-only during the native-XMR market transition.</p>}{l.kind==='auction'&&<p className="market-countdown"><Clock3 size={13}/>{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}</p>}
{l.status==='reserved'?<p className="market-reserved">Reserved · awaiting settlement</p>:legacy?mine&&['open','invalid'].includes(l.status)&&!l.highestBid?<button className="button outline full" disabled={!!disabled} onClick={()=>prepare({type:'retire-legacy-listing',listingId:l.id},`Close the USD legacy listing for ${l.title}? Its old price/signature remain historical; relisting requires a fresh XMR authorization.`)}>Close legacy listing to relist in XMR</button>:<p className="market-reserved">Read-only USD legacy listing · no new bids or checkout</p>:mine?<button className="button outline full" disabled={!!disabled||!!l.highestBid} onClick={()=>prepare({type:'cancel',listingId:l.id},`Cancel the listing for ${l.title}?`)}>Cancel listing</button>:l.kind==='fixed'||ended?<button className="button dark full" disabled={!!disabled||(ended&&l.highestBid?.bidder!==wallet?.address)} onClick={()=>prepare({type:'buy',listingId:l.id,priceAtomicXmr:saleAtomic!,feeBps:l.feeBps},`Reserve ${l.title} for ${formatXmr(parseAtomicXmr(saleAtomic!))} XMR with simulated checkout. ${feeDisclosure(l.feeBps)}. Complete mock payment within 30 minutes ${ended?'of auction closing':'of reservation'}.`)}><ShoppingBag size={14}/>{ended?'Winner checkout':'Buy now'}</button>:<div className="market-bid"><label className="sr-only" htmlFor={`bid-${l.id}`}>Your XMR bid for {l.title}</label><input id={`bid-${l.id}`} inputMode="decimal" value={bids[l.id]??''} placeholder={formatXmr(parseAtomicXmr(atomicXmrString(l.highestBid?BigInt(l.highestBid.priceAtomicXmr)+1n:BigInt(l.priceAtomicXmr))))} onChange={e=>setBids(v=>({...v,[l.id]:e.target.value}))}/><button className="button dark" disabled={!!disabled} onClick={()=>{try{const priceAtomicXmr=atomicXmrString(parseXmr(bids[l.id]??''));prepare({type:'bid',listingId:l.id,priceAtomicXmr},`Bid ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR on ${l.title}. If you win, check out within 30 minutes of closing. Bids are signed but not funded; all payment is simulated.`);}catch(e){setError(e instanceof Error?e.message:'Invalid bid');}}}><Gavel size={14}/>Bid</button></div>}
<p className="market-card-foot">{l.feeBps/100}% host + {(l.additionalFee?.bps ?? 0)/100}% author · deducted from seller · NFT {short(l.cardHash)}</p>{article&&<p className="market-card-foot"><a href={article.url} target="_blank" rel="noreferrer">Wikipedia</a> · <a href={imageCredit(article)} target="_blank" rel="noreferrer">Image & license</a> · <a href={`/api/nfts/${l.cardHash}`} target="_blank" rel="noreferrer">NFT metadata</a></p>}</div></article>;
})}</div>{!page?.listings.length&&<div className="market-empty"><ShoppingBag size={30}/><h2>A discovery worth sharing?</h2><p>The market is waiting for its first listing. Offer a card from your confirmed collection.</p><button className="button dark" onClick={()=>setTab('sell')}>List your first card</button></div>}{page?.next!==null&&page?.next!==undefined&&<button className="button outline" onClick={loadMore}>Load more listings</button>}</>}
{tab==='sell'&&<section className="market-sell"><div><div className="section-eyebrow">MAKE ROOM FOR THE NEXT DISCOVERY</div><h2>Put a card on the market.</h2><p>Keep a set price open indefinitely, or let collectors bid until the clock runs out. You retain custody of the card and must return to sign delivery after mock payment.</p><p>A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.</p></div><form onSubmit={e=>{e.preventDefault();create();}}><label htmlFor="market-card">YOUR MATURE, UNLISTED CARD</label><select id="market-card" value={cardHash} onChange={e=>setCardHash(e.target.value)} required><option value="">Choose a card…</option>{owned.map(b=><option key={b.tokenId} value={b.tokenId}>{b.article.title} · {short(b.tokenId)}</option>)}</select><label htmlFor="market-kind">SALE FORMAT</label><select id="market-kind" value={kind} onChange={e=>setKind(e.target.value as typeof kind)}><option value="fixed">Fixed price · no expiry</option><option value="auction">Timed auction</option></select><label htmlFor="market-price">{kind==='fixed'?'PRICE':'STARTING BID'} · USD</label><input id="market-price" inputMode="decimal" value={price} onChange={e=>setPrice(e.target.value)} required/>{kind==='auction'&&<><label htmlFor="market-hours">AUCTION LENGTH · HOURS</label><input id="market-hours" type="number" min="0.016667" max="720" step="any" value={hours} onChange={e=>setHours(e.target.value)} required/></>}<p className="muted">Fees: {page?(page.info.feeBps/100)+'% market + 1% additional':'—'} · both subtracted from seller proceeds, never added to the buyer price.</p><button className="button dark" type="submit" disabled={!!disabled||!owned.length}><Tag size={15}/>Review listing</button></form></section>}
{tab==='sell'&&<section className="market-sell"><div><div className="section-eyebrow">MAKE ROOM FOR THE NEXT DISCOVERY</div><h2>Put a card on the market.</h2><p>Choose a fixed XMR price or let collectors bid until the clock runs out. Enter up to 12 decimal places; one atomic unit is the smallest representable positive price. You retain custody of the card and must return to sign delivery after mock payment.</p><p>A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.</p></div><form onSubmit={e=>{e.preventDefault();create();}}><label htmlFor="market-card">YOUR MATURE, UNLISTED CARD</label><select id="market-card" value={cardHash} onChange={e=>setCardHash(e.target.value)} required><option value="">Choose a card…</option>{owned.map(b=><option key={b.tokenId} value={b.tokenId}>{b.article.title} · {short(b.tokenId)}</option>)}</select><label htmlFor="market-kind">SALE FORMAT</label><select id="market-kind" value={kind} onChange={e=>setKind(e.target.value as typeof kind)}><option value="fixed">Fixed price · no expiry</option><option value="auction">Timed auction · simulation only</option></select><label htmlFor="market-price">{kind==='fixed'?'PRICE':'STARTING BID'} · XMR</label><input id="market-price" inputMode="decimal" value={price} onChange={e=>setPrice(e.target.value)} required/><p className="muted">12 decimal places maximum. No USD conversion or minimum-price rule. Fees: {page?(page.info.feeBps/100)+'% host + 1% author':'—'} · rounded independently from gross proceeds.</p>{kind==='auction'&&<><label htmlFor="market-hours">AUCTION LENGTH · HOURS</label><input id="market-hours" type="number" min="0.016667" max="720" step="any" value={hours} onChange={e=>setHours(e.target.value)} required/></>}<button className="button dark" type="submit" disabled={!!disabled||!owned.length}><Tag size={15}/>Review listing</button></form></section>}
{tab==='orders'&&<section className="market-activity">
<h2>Orders & settlement</h2>
<p className="muted">Private order details require your wallet signature. No private keys leave this browser. Simulated payouts are automatic only after confirmed card delivery.</p>
<p className="muted">Private order details require your wallet signature. New prices use native XMR amounts; legacy orders retain their original USD terms. No private keys leave this browser. Payments and payouts remain simulated.</p>
{activity.orders.length===0&&<p>No orders yet. Explore the market to find your next discovery.</p>}
{activity.orders.map(o=>{
const buyer=o.buyer===wallet?.address;
return <article className="market-order" key={o.id}>
<header><div><strong>{snapshot?.blocks.find(b=>b.tokenId===o.cardHash)?.article.title??short(o.cardHash)}</strong><small>{buyer?'Buying from':'Selling to'} {short(buyer?o.seller:o.buyer)} · {short(o.id)}</small></div><span className="market-state">{o.status.replaceAll('-',' ')}</span></header>
<dl>
<div><dt>Sale</dt><dd>{formatUsd(o.priceCents)}</dd></div>
<div><dt>{isXmrOrder(o)?'Sale price':'Sale price · USD legacy'}</dt><dd>{orderPrice(o)}</dd></div>
<div><dt>Mock checkout</dt><dd>{o.invoice.amountXmr} XMR</dd></div>
<div><dt>Hosting peer fee ({o.feeBps/100}%) · simulated</dt><dd>{o.feeXmr} XMR</dd></div>
{o.additionalFee && <div><dt>Project author fee ({o.additionalFee.bps/100}%) · simulated</dt><dd>{o.additionalFee.amountXmr} XMR · {o.additionalFee.payout}<span className="market-fee-recipient">Recipient: {o.additionalFee.address}</span></dd></div>}
@@ -159,14 +175,14 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props)
</dl>
<p className="muted">{o.status==='awaiting-payment'?`Pay by ${new Date(o.payBy).toLocaleString()}`:o.status==='paid'?`Seller signs delivery by ${new Date(o.deliverBy).toLocaleString()}`:`Delivery: ${o.deliveryConfirmations}/${o.requiredConfirmations} confirmations · payout: ${o.payout}`}</p>
<div className="market-actions">
{buyer&&o.status==='awaiting-payment'&&<button className="button dark" disabled={!!disabled} onClick={()=>prepare({type:'mock-pay',orderId:o.id},`Simulate receipt of ${o.invoice.amountXmr} XMR for order ${short(o.id)}. This sends no money and is not a real payment confirmation.`)}>Simulate XMR payment</button>}
{buyer&&o.status==='awaiting-payment'&&<button className="button dark" disabled={!!disabled} onClick={()=>prepare({type:'mock-pay',orderId:o.id},`Simulate receipt of ${o.invoice.amountXmr} XMR for order ${short(o.id)}. This sends no money and is not a real payment confirmation. Sale price: ${orderPrice(o)}.`,!isXmrOrder(o))}>Simulate XMR payment</button>}
{!buyer&&(o.status==='paid'||o.status==='delivery-pending')&&<button className="button dark" disabled={!!disabled} onClick={()=>void deliver(o)}>Review & sign card delivery</button>}
{buyer&&o.status==='refund-required'&&<button className="button outline" disabled={!!disabled} onClick={()=>prepare({type:'refund',orderId:o.id},'Simulate a full buyer refund after failed delivery. No real funds will move.')}>Simulate full refund</button>}
{buyer&&o.status==='refund-required'&&<button className="button outline" disabled={!!disabled} onClick={()=>prepare({type:'refund',orderId:o.id},'Simulate a full buyer refund after failed delivery. No real funds will move.',!isXmrOrder(o))}>Simulate full refund</button>}
</div>
{o.status==='review'&&<p className="market-error">A chain reorganization affected settled delivery. Operator review required; no second payout is submitted.</p>}
</article>;
})}
<h2>Your listing history</h2>{activity.listings.map(l=><div className="market-history" key={l.id}><strong>{l.title}</strong><span>{formatUsd(l.highestBid?.priceCents??l.priceCents)}</span><span>{l.status}</span></div>)}
<h2>Your listing history</h2>{activity.listings.map(l=><div className="market-history" key={l.id}><strong>{l.title}</strong><span>{isXmrListing(l)?`${formatXmr(parseAtomicXmr(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr))} XMR`:listingPriceLegacy(l)}</span><span>{l.status}</span></div>)}
</section>}
</section>;
}