This commit is contained in:
2026-10-20 03:14:08 -04:00
parent b1d2b5c10e
commit 9850fd8554
28 changed files with 1256 additions and 26 deletions
+10 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle,
Pickaxe, Search, Shield, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
Pickaxe, Search, Shield, ShoppingBag, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
import { rarity } from '../shared/protocol.ts';
import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
@@ -11,10 +11,12 @@ import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
import { ThemePicker } from './ThemePicker.tsx';
import { TradeDesk } from './TradeDesk.tsx';
import { Marketplace } from './Marketplace.tsx';
import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts';
import './App.css';
import './testnet.css';
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks';
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks' | 'Marketplace';
const short = (s: string) => s ? `${s.slice(0, 6)}…${s.slice(-4)}` : '—';
const asCard = (block: TestnetBlock): CardData => ({ article: block.article, hash: block.powHash, block });
const message = (error: unknown) => error instanceof Error ? error.message : 'Request failed. Please try again.';
@@ -154,18 +156,18 @@ export default function App() {
<aside className="sidebar">
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><img className="brand-logo" src="/Wikideck.png" alt="" />wikideck</a>
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight][i];
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks', 'Marketplace'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight, ShoppingBag][i];
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
})}</nav>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>A little curiosity.<br />An independent chain.</h3><p>Collect knowledge as native NFTs on a sovereign RandomX testnet.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">TESTNET</span></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">{NETWORK_LABEL}</span></div>
<button className="help-link" onClick={() => setDialog('guide')}><CircleHelp size={16} /> The field guide <ArrowUpRight size={14} /></button>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>TESTNET</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>{NETWORK_LABEL}</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
<main>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>Unbootstrapped testnet · no public mainnet</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'Gated mainnet candidate · unaudited · mock payments only':'Unbootstrapped testnet · no public mainnet'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
{error && <div className="error-banner" role="alert"><CircleHelp size={17} /><span>{error}</span><button onClick={retry}>Retry</button><button aria-label="Dismiss error" onClick={() => setError('')}><X size={16} /></button></div>}
{view === 'Discover' && <>
<section className="hero">
@@ -183,7 +185,7 @@ export default function App() {
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.</p><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code>&lt;backup.json&gt;</code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
</section>
</>}
{view === 'Trade decks' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
{view === 'Marketplace' ? <Marketplace wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view === 'Trade decks' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical testnet chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<div className="filter-bar"><div className="category-tabs">{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => <button key={c} className={category === c ? 'selected' : ''} onClick={() => setCategory(c)}>{c}</button>)}</div><label className="search-input"><Search size={15} /><input aria-label="Search cards" placeholder="Find a discovery…" value={query} onChange={e => setQuery(e.target.value)} /></label></div>
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.block?.tokenId ?? c.hash}><Card card={c} onClick={() => { setSelected(c); setTransferError(''); }} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}</div>{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && <span className="pending-label">Transfer or trade pending mining</span>}</div>)}</div> : <div className="empty-state"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Explore native mining</button></div>}
+172
View File
@@ -0,0 +1,172 @@
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 { 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 };
const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`;
const feeDisclosure = (marketBps: number) => `Market fee: ${marketBps / 100}% plus a fixed 1% additional fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Additional fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`;
async function api<T>(body?: unknown, offset = 0): Promise<T> {
const response = await fetch(body ? '/api/market/command' : `/api/market?offset=${offset}`, {
cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Marketplace is unavailable on this node.');
const data = await response.json();
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> {
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)) });
}
function deadline(time: number, now: number) {
if (time <= now) return 'Closed';
const minutes = Math.ceil((time - now) / 60000);
return minutes >= 1440 ? `${Math.ceil(minutes / 1440)}d remaining` : minutes >= 60 ? `${Math.ceil(minutes / 60)}h remaining` : `${minutes}m remaining`;
}
export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
const [page, setPage] = useState<Page>();
const [activity, setActivity] = useState<MarketActivity>({ 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 [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 [now, setNow] = useState(Date.now());
const version = useRef(0);
const inFlight = useRef(false);
const reviewPanel = useRef<HTMLElement>(null);
useEffect(() => {
if (!review) return;
reviewPanel.current?.focus({ preventScroll: true });
reviewPanel.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
}, [review]);
const refresh = useCallback(async () => {
const current = version.current;
const data = await api<Page>();
if (data.info.network !== NETWORK || 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: [] };
if (current !== version.current) return;
setPage(data); setActivity(mine); setNow(Date.now());
}, [wallet, snapshot?.chainId]);
useEffect(() => {
version.current++; setPage(undefined); setActivity({ listings: [], orders: [] }); setReview(undefined);
let active = true;
const load = () => { void refresh().catch(e => { if (active) setError(e instanceof Error ? e.message : 'Could not load market.'); }); };
if (rpcAvailable) load();
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 submit = async () => {
if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return;
const currentVersion = version.current;
inFlight.current = true; setBusy(true); setError('');
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 (currentVersion !== version.current) return;
const type = review.action.type;
setReview(undefined);
if (type === 'buy' || type === 'mock-pay' || type === 'deliver' || type === 'refund') setTab('orders');
onNotice(type === 'deliver' ? 'Delivery submitted. Simulated seller payout waits for canonical confirmations.' : 'Marketplace updated. All XMR payments and payouts are simulations.');
await refresh();
} catch (e) { setError(e instanceof Error ? e.message : 'Marketplace action failed.'); }
finally { inFlight.current = false; setBusy(false); }
};
const listed = new Set(activity.listings.filter(l => l.status === 'open' || l.status === 'reserved').map(l => l.cardHash));
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address &&
snapshot.blocks.length + 1 - b.height >= CONSENSUS.coinbaseMaturity && !listed.has(b.tokenId) &&
!snapshot.pendingTransfers.some(tx => transactionCardIds(tx).includes(b.tokenId))) ?? [];
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);
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.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); }
};
const deliver = async (o: MarketOrder) => {
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)'}.`);
} catch { setError('Could not prepare delivery signature.'); }
};
const loadMore = async () => {
if (!page || page.next === null) return;
try {
const next = await api<Page>(undefined, page.next);
if (next.info.marketId !== page.info.marketId || next.info.chainId !== page.info.chainId) throw new Error('Market changed. Refresh first.');
setPage(current => current ? { ...next, listings: [...new Map([...current.listings, ...next.listings].map(l => [l.id, l])).values()] } : next);
} catch (e) { setError(e instanceof Error ? e.message : 'Could not load listings.'); }
};
const disabled = busy || !wallet || !rpcAvailable || !page;
return <section className="marketplace">
<header className="market-hero"><div><div className="section-eyebrow">THE COLLECTOR’S EXCHANGE</div><h1>A new home for<br/><span>extraordinary finds.</span></h1><p>Discover a fixed-price favorite or join a timed auction. Prices in USD, checkout in XMR. Your keys always sign card delivery.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>KNOWLEDGE CHANGES HANDS</span></div></header>
<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>Market fee <strong>{page.info.feeBps/100}% + 1% additional</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">Fixed 1% 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 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>}
<p className="market-card-foot">{l.feeBps/100}% market + {(l.additionalFee?.bps ?? 0)/100}% additional seller fee · 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==='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>
{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>Mock checkout</dt><dd>{o.invoice.amountXmr} XMR</dd></div>
<div><dt>Market fee ({o.feeBps/100}%)</dt><dd>{o.feeXmr} XMR</dd></div>
{o.additionalFee && <div><dt>Additional 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>}
<div><dt>Seller net · simulated</dt><dd>{o.sellerNetXmr} XMR</dd></div>
</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==='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>}
</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>)}
</section>}
</section>;
}
function ShieldNote() { return <span className="market-note-icon"><Clock3 size={20}/></span>; }
Executable
+3
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,7 +1,8 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { IS_MAINNET } from '../shared/network.ts';
export type Wallet = { privateKey: CryptoKey; publicKey: string; address: string };
const DATABASE = 'wikideck-testnet-wallet-v2';
const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2';
const LOCK = `${DATABASE}:identity`;
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));