src/blackrock/MarketBoard.tsx

import {ItemSearchInput} from './ItemSearchInput';
import {readExchangeViews,VIEW_KEY,type ExchangeView,type DeskState} from './exchange-views';
import {MarketPulse} from './MarketPulse';
import {RarityGuide} from './RarityGuide';
import {useCallback,useEffect,useRef,useState,type RefObject} from 'react';
import {Badge,Button,Select,Table,Card,SegmentedControlRadioGroup,Popover,TextField,Checkbox} from '@whop/react/components';
import {ArrowUpRight,RefreshCw,Search,SlidersHorizontal,Columns3,MoreHorizontal,Download} from 'lucide-react';
import {number,time,type Entity} from './api';
import {ItemArt} from './ItemArt';
import {ErrorMessage} from './Research';
import {holderSummary,readMarketVisits} from './market-board-model';
import {useMarketFeed} from './useMarketFeed';
import './market-board.css';
import './exchange-desk.css';
import {categories,matchesCategory,researchCategory,sortResearchRows,researchPrompt,type ResearchActions} from './research-interactions';
import {ItemResearchActions} from './ItemResearchActions';
import {SourcePayload} from './ResearchTools';
import {emptyDeskFilters,matchesDeskFilters,deskFilterError,deskColumns,exchangeCsv,type DeskFilters} from './exchange-desk-model';
type Book='recent'|'buy'|'sell';
const defaults:Record<Book,string>={recent:'recent',buy:'buyValue',sell:'sellRecent'};
const VISITS='rs-blackrock:market-visits';
const ranks=[['buyValue','Largest buy orders · GP'],['sellRecent','Latest sell listings'],['recent','Latest traded'],['volume','Most traded units'],['value','Largest traded value'],['rises','Price rises'],['falls','Price falls']];
const initialDesk=():DeskState=>{let density:DeskState['density']='compact';try{if(localStorage.getItem('rs-blackrock:exchange-density')==='comfortable')density='comfortable';}catch{}return {category:'all',column:'source',direction:'desc',filters:emptyDeskFilters(),density,hidden:['lastSellAt','lastTradeAt']};};
export function MarketBoard({active,onSelect,query='',searchValue=query,onSearch,searchRef,...actions}:ResearchActions&{active:boolean;query?:string;searchValue?:string;onSearch?:(q:string)=>void;searchRef?:RefObject<HTMLInputElement|null>;onSelect:(e:Entity)=>void}){
 const [book,setBook]=useState<Book>('buy'),[sort,setSort]=useState('buyValue'),[days,setDays]=useState('7'),[desk,setDesk]=useState(initialDesk);
 const [views,setViews]=useState<ExchangeView[]>(()=>{try{return readExchangeViews(localStorage.getItem(VIEW_KEY))}catch{return []}});
 const [viewName,setViewName]=useState(''),[viewMessage,setViewMessage]=useState('');
 const saveView=()=>{const name=viewName.trim();if(!name)return;const next=[...views.filter(v=>v.name!==name),{id:crypto.randomUUID(),name,query:searchValue,book,sort,days,desk}].slice(-20);try{localStorage.setItem(VIEW_KEY,JSON.stringify(next));setViews(next);setViewName('');setViewMessage(`Saved “${name}” in this browser.`)}catch{setViewMessage('Unable to save. Browser storage is unavailable.')}};
 const removeView=(id:string)=>{const next=views.filter(v=>v.id!==id);try{localStorage.setItem(VIEW_KEY,JSON.stringify(next));setViews(next);setViewMessage('Saved view removed. Current table is unchanged.')}catch{setViewMessage('Unable to update browser storage.')}};
 const applyView=(id:string)=>{const view=views.find(v=>v.id===id);if(!view)return;setBook(view.book);setSort(view.sort);setDays(view.days);setDesk(view.desk);onSearch?.(view.query);setViewMessage(`Applied “${view.name}”: search, filters, sorting, columns, density and period.`)};
 const board=useRef<HTMLElement>(null),bar=useRef<HTMLDivElement>(null);
 useEffect(()=>{const node=bar.current,root=board.current;if(!node||!root)return;const workspace=root.closest<HTMLElement>('.br-workspace');const resize=()=>{root.style.setProperty('--exchange-bar-height',`${node.getBoundingClientRect().height}px`);if(workspace)root.style.setProperty('--exchange-workspace-height',`${Math.min(workspace.clientHeight,window.innerHeight)}px`);};resize();const observer=new ResizeObserver(resize);observer.observe(node);if(workspace)observer.observe(workspace);return()=>observer.disconnect();},[]);
 useEffect(()=>{try{localStorage.setItem('rs-blackrock:exchange-density',desk.density);}catch{}},[desk.density]);
 return <section ref={board} className={`market-board exchange-desk exchange-${desk.density}`} aria-label="Exchange market board">
  <div className="market-board-head"><div><span className="br-eyebrow">PUBLIC GRAND EXCHANGE</span><h2>Order book</h2></div><span className="exchange-side-help">{book==='buy'?'Buyers’ bids · demand to sell into':book==='sell'?'Sellers’ asks · supply available to buy':'Completed trades · latest execution per item'}</span></div>
  <div className="exchange-saved-views"><Select.Root value="" onValueChange={applyView}><Select.Trigger aria-label="Saved Exchange views" placeholder="Saved views"/><Select.Content>{views.length?views.map(v=><Select.Item key={v.id} value={v.id}>{v.name}</Select.Item>):<Select.Item disabled value="none">No saved views yet</Select.Item>}</Select.Content></Select.Root><Popover.Root><Popover.Trigger><Button variant="ghost">Save current view</Button></Popover.Trigger><Popover.Content size="2"><form onSubmit={e=>{e.preventDefault();saveView()}}><label>View name<TextField.Input aria-label="Saved view name" value={viewName} onChange={e=>setViewName(e.target.value)} placeholder="e.g. Cannon supplies" maxLength={60}/></label><p>Save search, filters, sorting, columns, density and period in this browser.</p><Button type="submit" disabled={!viewName.trim()}>Save view</Button></form>{views.length>0&&<div className="exchange-view-management"><strong>Manage saved views</strong>{views.map(v=><div key={v.id}><span>{v.name}</span><Button size="1" variant="ghost" aria-label={`Remove saved view ${v.name}`} onClick={()=>removeView(v.id)}>Remove</Button></div>)}</div>}</Popover.Content></Popover.Root><span role="status">{viewMessage}</span></div>
  <div ref={bar} className="exchange-primary-bar">
   <SegmentedControlRadioGroup.Root className="market-book-tabs" aria-label="Market order views" value={book} onValueChange={v=>{setBook(v as Book);setSort(defaults[v as Book]);}}><SegmentedControlRadioGroup.Item value="recent">Recent trades</SegmentedControlRadioGroup.Item><SegmentedControlRadioGroup.Item value="buy">Buy orders</SegmentedControlRadioGroup.Item><SegmentedControlRadioGroup.Item value="sell">Sell orders</SegmentedControlRadioGroup.Item></SegmentedControlRadioGroup.Root>
   <div className="exchange-search"><Search size={16}/><ItemSearchInput inputRef={searchRef} id="br-exchange-search" label="Search exchange items" value={searchValue} onChange={value=>onSearch?.(value)} onPick={item=>onSearch?.(item.name)}/>{searchValue&&<Button variant="ghost" size="1" onClick={()=>onSearch?.('')} aria-label="Clear exchange search">Clear</Button>}</div>
  </div>
  <OrderFeed key={`${query}|${sort}|${days}|${book}`} {...{active,onSelect,query,sort,days,book,desk,setDesk,setSort,setDays}} {...actions}/>
 </section>;
}
function OrderFeed({active,onSelect,query,sort,days,book,desk,setDesk,setSort,setDays,...actions}:ResearchActions&{active:boolean;onSelect:(e:Entity)=>void;query:string;sort:string;days:string;book:Book;desk:DeskState;setDesk:React.Dispatch<React.SetStateAction<DeskState>>;setSort:(s:string)=>void;setDays:(s:string)=>void}){
 const list=useRef<HTMLDivElement>(null),sentinel=useRef<HTMLDivElement>(null),toolbar=useRef<HTMLDivElement>(null);
 useEffect(()=>{const node=toolbar.current,root=node?.closest<HTMLElement>('.exchange-desk');if(!node||!root)return;const resize=()=>root.style.setProperty('--exchange-toolbar-height',`${node.getBoundingClientRect().height}px`);resize();const observer=new ResizeObserver(resize);observer.observe(node);return()=>observer.disconnect();},[]);
 const [automatic,setAutomatic]=useState(true),[visits,setVisits]=useState<Record<string,number>>(()=>{try{return readMarketVisits(localStorage.getItem(VISITS));}catch{return {};}});
 const atTop=useCallback(()=>!list.current||list.current.scrollTop<10,[]);
 const source=useMarketFeed(`/research/overview?query=${encodeURIComponent(query)}&sort=${sort}&days=${days}`,active,automatic,atTop);
 const first=source.pages[0],latest=source.pages.at(-1),summary=first?.summary;
 const {category,column,direction,filters}=desk;
 const rows=sortResearchRows(source.items.filter(r=>(book==='buy'?r.buyQuantity>0:book==='sell'?r.sellQuantity>0:true)&&matchesCategory(r,category,actions.saved)&&matchesDeskFilters(r,book,filters,visits)),column,direction,visits);
 const context={section:'Exchange',query,book,marketRanking:sort,loadedSort:column,direction,category,filters,days,loaded:source.items.length,shown:rows.length,total:source.total,hasMore:source.nextOffset!==null,sourceUpdatedAt:latest?.sourceUpdatedAt};
 const change=(patch:Partial<DeskState>)=>setDesk(old=>({...old,...patch}));
 const chooseColumn=(key:string)=>change({direction:column===key&&direction==='desc'?'asc':'desc',column:key});
 const allColumns=deskColumns(book),columns=allColumns.filter(c=>!desk.hidden.includes(c.key));
 const filterCount=(category==='all'?0:1)+Object.values(filters).filter(Boolean).length;
 const reset=()=>change({category:'all',filters:emptyDeskFilters(),column:'source',direction:'desc'});
 const filterError=deskFilterError(filters);
 useEffect(()=>{list.current?.scrollTo({top:0});},[category,column,direction,filters]);
 useEffect(()=>{
  if(!active||source.loading||source.error||filterError||source.nextOffset===null||!sentinel.current||!list.current)return;
  const observer=new IntersectionObserver(entries=>{if(entries.some(e=>e.isIntersecting))source.loadMore();},{root:list.current,rootMargin:'160px 0px'});
  observer.observe(sentinel.current);return()=>observer.disconnect();
 },[active,source.loading,source.error,source.nextOffset,source.items.length,filterError,rows.length]);
 const open=(r:any,view:Entity['view']='overview')=>{const next=readMarketVisits(JSON.stringify({...visits,[r.id]:Date.now()}));setVisits(next);try{localStorage.setItem(VISITS,JSON.stringify(next));}catch{}onSelect({kind:'item',key:String(r.id),itemId:r.id,name:r.name,view});};
 const refresh=async()=>{if(await source.refresh())list.current?.scrollTo({top:0});};
 const exportRows=()=>{const url=URL.createObjectURL(new Blob([exchangeCsv(rows,columns,visits)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=`exchange-${book}-filtered-loaded-${new Date().toISOString().slice(0,10)}.csv`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
 return <>
  <div ref={toolbar} className="exchange-toolbar" aria-label="Exchange table controls">
   <Select.Root value={sort} onValueChange={setSort}><Select.Trigger aria-label="Rank market by"/><Select.Content>{ranks.map(([v,n])=><Select.Item key={v} value={v}>{n}</Select.Item>)}</Select.Content></Select.Root>
   <Select.Root value={days} onValueChange={setDays}><Select.Trigger aria-label="Market board window"/><Select.Content>{['1','7','30','90'].map(d=><Select.Item key={d} value={d}>{d==='1'?'24 hours':`${d} days`}</Select.Item>)}</Select.Content></Select.Root>
   <Popover.Root><Popover.Trigger><Button variant={filterCount?'soft':'surface'} color="gray"><SlidersHorizontal size={15}/>Filters{filterCount?` (${filterCount})`:''}</Button></Popover.Trigger><Popover.Content className="exchange-options" size="2" aria-label="Filter market rows">
    <strong>Filter loaded items</strong><label>Category<Select.Root value={category} onValueChange={v=>change({category:v})}><Select.Trigger aria-label="Item category"/><Select.Content>{categories.map(([v,n])=><Select.Item key={v} value={v}>{n}</Select.Item>)}</Select.Content></Select.Root></label>
    <div className="exchange-number-filters">{([['minPrice',`Min ${book==='buy'?'bid':book==='sell'?'ask':'trade price'} · GP`],['maxPrice','Max price · GP'],['minQuantity','Min quantity'],['minValue','Min total value · GP']] as const).map(([key,label])=><label key={key}>{label}<TextField.Input type="number" min="0" step="any" aria-label={label} value={filters[key]} placeholder="Any" onChange={e=>change({filters:{...filters,[key]:e.target.value}})}/></label>)}</div>
    <label className="exchange-check"><Checkbox checked={filters.twoSided} onCheckedChange={v=>change({filters:{...filters,twoSided:v===true}})}/>Both bids and asks</label><label className="exchange-check"><Checkbox checked={filters.unvisited} onCheckedChange={v=>change({filters:{...filters,unvisited:v===true}})}/>Not visited in this browser</label>
    <label>Sort loaded rows<Select.Root value={column} onValueChange={v=>change({column:v})}><Select.Trigger aria-label="Sort loaded rows"/><Select.Content><Select.Item value="source">Market ranking order</Select.Item><Select.Item value="rarity">Rarity focus · research tiers</Select.Item>{allColumns.map(c=><Select.Item value={c.key} key={c.key}>{c.label}</Select.Item>)}</Select.Content></Select.Root></label>
    <Button disabled={column==='source'} variant="soft" onClick={()=>change({direction:direction==='desc'?'asc':'desc'})}>{direction==='desc'?'Descending ↓':'Ascending ↑'}</Button><Button variant="ghost" onClick={reset}>Reset filters & sort</Button><p>{filterError??'Numeric filters use the selected order side. Unknown values do not match a numeric limit. Filters and column sorting apply to loaded rows.'}</p>
   </Popover.Content></Popover.Root>
   <Popover.Root><Popover.Trigger><Button variant="surface" color="gray"><Columns3 size={15}/>Display</Button></Popover.Trigger><Popover.Content className="exchange-options" size="2" aria-label="Table display options"><strong>Row density</strong><SegmentedControlRadioGroup.Root aria-label="Row density" value={desk.density} onValueChange={v=>change({density:v as DeskState['density']})}><SegmentedControlRadioGroup.Item value="compact">Compact</SegmentedControlRadioGroup.Item><SegmentedControlRadioGroup.Item value="comfortable">Comfortable</SegmentedControlRadioGroup.Item></SegmentedControlRadioGroup.Root><strong>Columns</strong>{allColumns.filter(c=>c.key!=='name').map(c=><label className="exchange-check" key={c.key}><Checkbox checked={!desk.hidden.includes(c.key)} onCheckedChange={v=>change({hidden:v===true?desk.hidden.filter(k=>k!==c.key):[...desk.hidden,c.key]})}/>{c.label}</label>)}<Button variant="ghost" onClick={()=>change({hidden:[]})}>Show all columns</Button></Popover.Content></Popover.Root>
   <Button variant="ghost" color="gray" disabled={source.loading} aria-label="Refresh exchange list" onClick={()=>void refresh()}><RefreshCw size={15}/></Button>
   <Button variant="soft" disabled={!rows.length} onClick={()=>actions.onAsk(researchPrompt('Analyze these filtered market results and rank the best next research candidates.',context,rows))}>Ask AI</Button>
   <Button variant="ghost" color="gray" disabled={!rows.length} onClick={exportRows} title="Export shown, loaded rows and visible columns"><Download size={15}/>CSV</Button>
   <Popover.Root><Popover.Trigger><Button variant="ghost" color="gray">Data & refresh</Button></Popover.Trigger><Popover.Content className="exchange-data-menu" size="2"><div className="exchange-footer"><span>{latest?`Fetched ${time(latest.sources?.[0]?.fetchedAt)} · Source ${time(latest.sourceUpdatedAt)}`:'Loading public market…'}</span><Button size="1" variant="ghost" color="gray" aria-pressed={automatic} onClick={()=>setAutomatic(v=>!v)}>{automatic?'Pause auto-check':'Resume auto-check'}</Button><span>{!automatic?'Paused':source.pages.length>1?'Refresh held while browsing':'Checks every 60s at top'}</span></div>
  <details className="exchange-method"><summary>Sources, coverage & category definitions</summary><p>Current aggregated orders; quantities and values cover all prices. Best-price fill depth is unknown. Market totals cover the whole market, not your filtered rows. Column sorting, numeric filters and categories apply to loaded items; scroll to include more. Search and source ranking request matching public results.</p><p>Last visited is saved in this browser. CSV exports the shown loaded rows and visible columns, with blank unknown values and UTC timestamps. Listings can change between reads. Rarity focus ranks research baskets, not measured scarcity.</p><RarityGuide/><SourcePayload data={{pages:source.pages,loadedUnique:source.items.length,hasMore:source.nextOffset!==null}} name="exchange-loaded-pages"/></details></Popover.Content></Popover.Root>
   {(filterCount>0||column!=='source')&&<Button variant="ghost" color="gray" onClick={reset}>Clear filters</Button>}
   <div className="exchange-coverage" role="status"><span>{rows.length} shown · {source.items.length}/{number(source.total)} loaded{source.nextOffset!==null?' · scroll for more':''}</span><span>{filterCount?`${filterCount} filters · `:''}{column==='source'?'Source ranking':`${allColumns.find(c=>c.key===column)?.label??'Rarity focus'} ${direction==='desc'?'↓':'↑'} · loaded rows`}</span></div>
  </div>
  {summary&&<div className="exchange-summary" aria-label="Market-wide totals"><span>Trade value · {days}d <strong>{number(summary.gross)} GP</strong></span><span>Traded units <strong>{number(summary.volume)}</strong></span><span>{number(summary.activeItems)} active items</span>{first?.featured&&<Button size="1" variant="ghost" color="gray" onClick={()=>open(first.featured)}>Most traded: {first.featured.name} · {number(first.featured.volume)}</Button>}</div>}
  <ErrorMessage message={source.error??filterError}/>
  <MarketPulse rows={filterError?[]:rows} book={book} days={days} loaded={source.items.length} total={source.total} updatedAt={latest?.sourceUpdatedAt} loading={source.loading} error={source.error??filterError} onInspect={r=>open(r,'charts')} {...actions}/>
  <div ref={list} className="market-board-table" tabIndex={0} aria-label={`${book==='buy'?'Buy orders':book==='sell'?'Sell orders':'Recent trades'} scrollable list`} aria-busy={source.loading}>
   <Table.Root variant="surface" size="1"><Table.Table><Table.Header><Table.Row>{columns.map(c=><Table.ColumnHeaderCell key={c.key} className={c.numeric?'exchange-number':''} aria-sort={column===c.key?(direction==='asc'?'ascending':'descending'):'none'}><Button size="1" variant="ghost" color="gray" aria-label={`Sort by ${c.label}`} onClick={()=>chooseColumn(c.key)}>{c.label}{column===c.key?(direction==='asc'?' ↑':' ↓'):' ↕'}</Button></Table.ColumnHeaderCell>)}<Table.ColumnHeaderCell className="exchange-action-cell">Actions</Table.ColumnHeaderCell></Table.Row></Table.Header><Table.Body>{rows.map((r:any)=><Table.Row key={r.id}>{columns.map(c=><Table.Cell key={c.key} className={c.numeric?`exchange-number ${c.key==='bid'?'market-bid':c.key==='ask'?'market-ask':''}`:''}>{c.key==='name'?<Button variant="ghost" color="gray" className="market-board-item" onClick={()=>open(r)} title={`${r.name} · #${r.id} · ${researchCategory(r).label}`}><ItemArt id={r.id} name={r.name}/><span><strong>{r.name}</strong>{desk.density==='comfortable'&&researchCategory(r).rank>0&&<small>{researchCategory(r).label}</small>}</span></Button>:c.key==='visited'?visits[r.id]?time(visits[r.id]):'Not visited':['lastSellAt','lastTradeAt'].includes(c.key)?r[c.key]?time(r[c.key]):'Not observed':number(r[c.key])}</Table.Cell>)}
    <Table.Cell className="exchange-action-cell"><Popover.Root><Popover.Trigger><Button size="1" variant="ghost" color="gray" aria-label={`Research ${r.name}`}><MoreHorizontal size={18}/></Button></Popover.Trigger><Popover.Content className="exchange-row-menu" size="2"><strong>{r.name}</strong><ItemResearchActions row={r} context={context} {...actions}/><Button variant="soft" color="gray" onClick={()=>open(r,'holders')}>Holders<ArrowUpRight size={12}/></Button><Button variant="ghost" color="gray" onClick={()=>open(r,'charts')}>Chart</Button></Popover.Content></Popover.Root></Table.Cell>
   </Table.Row>)}</Table.Body></Table.Table></Table.Root>
   <div ref={sentinel} className="market-feed-sentinel" role="status">{filterError?'Adjust the numeric filters above.':source.loading?'Loading items…':source.error?<Button variant="soft" onClick={()=>source.retry()}>Retry loading</Button>:source.nextOffset!==null?<Button variant="ghost" onClick={()=>source.loadMore()}>{rows.length?'Load more items':'No matches yet · search more items'}</Button>:rows.length?'End of results':'No items match. Try clearing the search or filters.'}</div>
  </div>

 </>;
}
export function HolderSummary({rows,itemId}:{rows:any[];itemId:number}){
 const data=holderSummary(rows,itemId);
 if(!data.count)return null;
 return <Card className="holder-summary" size="2"><span className="br-eyebrow">VISIBLE SUPPLY CONCENTRATION</span><div className="holder-summary-grid"><div><small>Matching units observed</small><strong>{number(data.total)}</strong></div><div><small>Largest observed holder</small><strong>{data.leader?.name}</strong><span>{number(data.leader?.quantity)} units · {number(data.share)}% of this sample</span></div><div><small>Accounts with matching previews</small><strong>{data.count}</strong></div></div><p>Only returned public bank previews. This is not total game supply, carried inventory, or proof that a holder placed an order.</p></Card>
}