|
|
import apiClient from './apiClient.js'; |
|
|
import { formatCurrency, formatPercent, createSkeletonRows } from './uiUtils.js'; |
|
|
|
|
|
class MarketView { |
|
|
constructor(section, wsClient) { |
|
|
this.section = section; |
|
|
this.wsClient = wsClient; |
|
|
this.tableBody = section.querySelector('[data-market-body]'); |
|
|
this.searchInput = section.querySelector('[data-market-search]'); |
|
|
this.timeframeButtons = section.querySelectorAll('[data-timeframe]'); |
|
|
this.liveToggle = section.querySelector('[data-live-toggle]'); |
|
|
this.drawer = section.querySelector('[data-market-drawer]'); |
|
|
this.drawerClose = section.querySelector('[data-close-drawer]'); |
|
|
this.drawerSymbol = section.querySelector('[data-drawer-symbol]'); |
|
|
this.drawerStats = section.querySelector('[data-drawer-stats]'); |
|
|
this.drawerNews = section.querySelector('[data-drawer-news]'); |
|
|
this.chartWrapper = section.querySelector('[data-chart-wrapper]'); |
|
|
this.chartCanvas = this.chartWrapper?.querySelector('#market-detail-chart'); |
|
|
this.chart = null; |
|
|
this.coins = []; |
|
|
this.filtered = []; |
|
|
this.currentTimeframe = '7d'; |
|
|
this.liveUpdates = false; |
|
|
} |
|
|
|
|
|
async init() { |
|
|
this.tableBody.innerHTML = createSkeletonRows(10, 7); |
|
|
await this.loadCoins(); |
|
|
this.bindEvents(); |
|
|
} |
|
|
|
|
|
bindEvents() { |
|
|
if (this.searchInput) { |
|
|
this.searchInput.addEventListener('input', () => this.filterCoins()); |
|
|
} |
|
|
this.timeframeButtons.forEach((btn) => { |
|
|
btn.addEventListener('click', () => { |
|
|
this.timeframeButtons.forEach((b) => b.classList.remove('active')); |
|
|
btn.classList.add('active'); |
|
|
this.currentTimeframe = btn.dataset.timeframe; |
|
|
if (this.drawer?.classList.contains('active') && this.drawerSymbol?.dataset.symbol) { |
|
|
this.openDrawer(this.drawerSymbol.dataset.symbol); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
if (this.liveToggle) { |
|
|
this.liveToggle.addEventListener('change', (event) => { |
|
|
this.liveUpdates = event.target.checked; |
|
|
if (this.liveUpdates) { |
|
|
this.wsSubscription = this.wsClient.subscribe('price_update', (payload) => this.applyLiveUpdate(payload)); |
|
|
} else if (this.wsSubscription) { |
|
|
this.wsSubscription(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
if (this.drawerClose) { |
|
|
this.drawerClose.addEventListener('click', () => this.drawer.classList.remove('active')); |
|
|
} |
|
|
} |
|
|
|
|
|
async loadCoins() { |
|
|
const result = await apiClient.getTopCoins(50); |
|
|
if (!result.ok) { |
|
|
this.tableBody.innerHTML = ` |
|
|
<tr><td colspan="8"> |
|
|
<div class="inline-message inline-error"> |
|
|
<strong>Unable to load coins</strong> |
|
|
<p>${result.error}</p> |
|
|
</div> |
|
|
</td></tr>`; |
|
|
return; |
|
|
} |
|
|
|
|
|
const data = result.data || {}; |
|
|
this.coins = data.coins || data || []; |
|
|
this.filtered = [...this.coins]; |
|
|
this.renderTable(); |
|
|
} |
|
|
|
|
|
filterCoins() { |
|
|
const term = this.searchInput.value.toLowerCase(); |
|
|
this.filtered = this.coins.filter((coin) => { |
|
|
const name = `${coin.name} ${coin.symbol}`.toLowerCase(); |
|
|
return name.includes(term); |
|
|
}); |
|
|
this.renderTable(); |
|
|
} |
|
|
|
|
|
renderTable() { |
|
|
this.tableBody.innerHTML = this.filtered |
|
|
.map( |
|
|
(coin, index) => ` |
|
|
<tr data-symbol="${coin.symbol}" class="market-row"> |
|
|
<td>${index + 1}</td> |
|
|
<td> |
|
|
<div class="chip">${coin.symbol || '—'}</div> |
|
|
</td> |
|
|
<td>${coin.name || 'Unknown'}</td> |
|
|
<td>${formatCurrency(coin.price)}</td> |
|
|
<td class="${coin.change_24h >= 0 ? 'text-success' : 'text-danger'}"> |
|
|
<span class="table-change-icon ${coin.change_24h >= 0 ? 'positive' : 'negative'}"> |
|
|
${coin.change_24h >= 0 ? |
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' : |
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' |
|
|
} |
|
|
</span> |
|
|
${formatPercent(coin.change_24h)} |
|
|
</td> |
|
|
<td>${formatCurrency(coin.volume_24h)}</td> |
|
|
<td>${formatCurrency(coin.market_cap)}</td> |
|
|
</tr> |
|
|
`, |
|
|
) |
|
|
.join(''); |
|
|
this.section.querySelectorAll('.market-row').forEach((row) => { |
|
|
row.addEventListener('click', () => this.openDrawer(row.dataset.symbol)); |
|
|
}); |
|
|
} |
|
|
|
|
|
async openDrawer(symbol) { |
|
|
if (!symbol) return; |
|
|
this.drawerSymbol.textContent = symbol; |
|
|
this.drawerSymbol.dataset.symbol = symbol; |
|
|
this.drawer.classList.add('active'); |
|
|
this.drawerStats.innerHTML = '<p>Loading...</p>'; |
|
|
this.drawerNews.innerHTML = '<p>Loading news...</p>'; |
|
|
await Promise.all([this.loadCoinDetails(symbol), this.loadCoinNews(symbol)]); |
|
|
} |
|
|
|
|
|
async loadCoinDetails(symbol) { |
|
|
const [details, chart] = await Promise.all([ |
|
|
apiClient.getCoinDetails(symbol), |
|
|
apiClient.getPriceChart(symbol, this.currentTimeframe), |
|
|
]); |
|
|
|
|
|
if (!details.ok) { |
|
|
this.drawerStats.innerHTML = `<div class="inline-message inline-error">${details.error}</div>`; |
|
|
} else { |
|
|
const coin = details.data || {}; |
|
|
this.drawerStats.innerHTML = ` |
|
|
<div class="grid-two"> |
|
|
<div> |
|
|
<h4>Price</h4> |
|
|
<p class="stat-value">${formatCurrency(coin.price)}</p> |
|
|
</div> |
|
|
<div> |
|
|
<h4>24h Change</h4> |
|
|
<p class="stat-value ${coin.change_24h >= 0 ? 'text-success' : 'text-danger'}">${formatPercent(coin.change_24h)}</p> |
|
|
</div> |
|
|
<div> |
|
|
<h4>High / Low</h4> |
|
|
<p>${formatCurrency(coin.high_24h)} / ${formatCurrency(coin.low_24h)}</p> |
|
|
</div> |
|
|
<div> |
|
|
<h4>Market Cap</h4> |
|
|
<p>${formatCurrency(coin.market_cap)}</p> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
if (!chart.ok) { |
|
|
if (this.chartWrapper) { |
|
|
this.chartWrapper.innerHTML = `<div class="inline-message inline-error">${chart.error}</div>`; |
|
|
} |
|
|
} else { |
|
|
|
|
|
const chartData = chart.data || {}; |
|
|
const points = chartData.data || chartData || []; |
|
|
this.renderChart(points); |
|
|
} |
|
|
} |
|
|
|
|
|
renderChart(points) { |
|
|
if (!this.chartWrapper) return; |
|
|
if (!this.chartCanvas || !this.chartWrapper.contains(this.chartCanvas)) { |
|
|
this.chartWrapper.innerHTML = '<canvas id="market-detail-chart" height="180"></canvas>'; |
|
|
this.chartCanvas = this.chartWrapper.querySelector('#market-detail-chart'); |
|
|
} |
|
|
const labels = points.map((point) => point.time || point.timestamp); |
|
|
const data = points.map((point) => point.price || point.value); |
|
|
if (this.chart) { |
|
|
this.chart.destroy(); |
|
|
} |
|
|
this.chart = new Chart(this.chartCanvas, { |
|
|
type: 'line', |
|
|
data: { |
|
|
labels, |
|
|
datasets: [ |
|
|
{ |
|
|
label: `${this.drawerSymbol.textContent} Price`, |
|
|
data, |
|
|
fill: false, |
|
|
borderColor: '#38bdf8', |
|
|
tension: 0.3, |
|
|
}, |
|
|
], |
|
|
}, |
|
|
options: { |
|
|
animation: false, |
|
|
scales: { |
|
|
x: { ticks: { color: 'var(--text-muted)' } }, |
|
|
y: { ticks: { color: 'var(--text-muted)' } }, |
|
|
}, |
|
|
plugins: { legend: { display: false } }, |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|
|
|
async loadCoinNews(symbol) { |
|
|
const result = await apiClient.getLatestNews(5); |
|
|
if (!result.ok) { |
|
|
this.drawerNews.innerHTML = `<div class="inline-message inline-error">${result.error}</div>`; |
|
|
return; |
|
|
} |
|
|
const related = (result.data || []).filter((item) => (item.symbols || []).includes(symbol)); |
|
|
if (!related.length) { |
|
|
this.drawerNews.innerHTML = '<p>No related headlines available.</p>'; |
|
|
return; |
|
|
} |
|
|
this.drawerNews.innerHTML = related |
|
|
.map( |
|
|
(news) => ` |
|
|
<article class="news-item"> |
|
|
<h4>${news.title}</h4> |
|
|
<p>${news.summary || ''}</p> |
|
|
<small>${new Date(news.published_at || news.date).toLocaleString()}</small> |
|
|
</article> |
|
|
`, |
|
|
) |
|
|
.join(''); |
|
|
} |
|
|
|
|
|
applyLiveUpdate(payload) { |
|
|
if (!this.liveUpdates) return; |
|
|
const symbol = payload.symbol || payload.ticker; |
|
|
if (!symbol) return; |
|
|
const row = this.section.querySelector(`tr[data-symbol="${symbol}"]`); |
|
|
if (!row) return; |
|
|
const priceCell = row.children[3]; |
|
|
const changeCell = row.children[4]; |
|
|
if (payload.price) { |
|
|
priceCell.textContent = formatCurrency(payload.price); |
|
|
} |
|
|
if (payload.change_24h) { |
|
|
changeCell.textContent = formatPercent(payload.change_24h); |
|
|
changeCell.classList.toggle('text-success', payload.change_24h >= 0); |
|
|
changeCell.classList.toggle('text-danger', payload.change_24h < 0); |
|
|
} |
|
|
row.classList.add('flash'); |
|
|
setTimeout(() => row.classList.remove('flash'), 600); |
|
|
} |
|
|
} |
|
|
|
|
|
export default MarketView; |
|
|
|