/** * MarketPulse — Main Application Module * Mobile navigation, table sorting, tab switching, back-to-top, * smooth scrolling, and number formatting. * Vanilla JS, no frameworks, no module exports. */ /* ============================================================ DOM Ready — init everything ============================================================ */ document.addEventListener('DOMContentLoaded', function () { try { initHamburgerMenu(); initTableSorting(); initTabSwitching(); initBackToTop(); initSmoothScroll(); } catch (err) { console.error('[main] Initialisation error:', err); } }); /* ============================================================ 1. Mobile Hamburger Menu ============================================================ */ /** * Toggle the mobile navigation menu when the hamburger button is * clicked. Toggles .active on the button and .open on the * mobile nav container. */ function initHamburgerMenu() { try { const hamburger = document.querySelector('.hamburger'); const mobileNav = document.querySelector('.header__nav-mobile'); if (!hamburger || !mobileNav) return; hamburger.addEventListener('click', function (e) { e.stopPropagation(); hamburger.classList.toggle('active'); mobileNav.classList.toggle('open'); }); // Close mobile nav when a link inside it is clicked const navLinks = mobileNav.querySelectorAll('a'); navLinks.forEach(function (link) { link.addEventListener('click', function () { hamburger.classList.remove('active'); mobileNav.classList.remove('open'); }); }); // Close mobile nav on click outside document.addEventListener('click', function (e) { if (!hamburger.contains(e.target) && !mobileNav.contains(e.target)) { hamburger.classList.remove('active'); mobileNav.classList.remove('open'); } }); } catch (err) { console.error('[main] Hamburger menu error:', err); } } /* ============================================================ 2. Table Sorting ============================================================ */ /** * Enable click-to-sort on all
| toggles ascending / * descending sort for that column and re-orders the | rows. * * CSS classes .sort-asc and .sort-desc are applied to the * active for visual feedback (defined in style.css).
*/
function initTableSorting() {
try {
const tables = document.querySelectorAll('.data-table');
if (!tables.length) return;
tables.forEach(function (table) {
const headers = table.querySelectorAll('th');
if (!headers.length) return;
headers.forEach(function (header, index) {
header.addEventListener('click', function () {
sortTable(table, index, header);
});
});
});
} catch (err) {
console.error('[main] Table sorting init error:', err);
}
}
/**
* Sort a table by the given column index, toggling direction.
*/
function sortTable(table, colIndex, headerEl) {
try {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (!rows.length) return;
// Determine new sort direction
const allTh = table.querySelectorAll('th');
let direction = 'asc';
// Remove sort classes from all headers
allTh.forEach(function (th) {
th.classList.remove('sort-asc', 'sort-desc');
});
// If this column was already ascending, flip to descending
if (headerEl.classList.contains('sort-asc')) {
direction = 'desc';
}
headerEl.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
// Sort the rows
const sorted = rows.sort(function (a, b) {
const aCell = a.querySelectorAll('td')[colIndex];
const bCell = b.querySelectorAll('td')[colIndex];
if (!aCell || !bCell) return 0;
let aVal = aCell.textContent.trim();
let bVal = bCell.textContent.trim();
// Try numeric sorting first (strip $, commas, % signs)
const aNum = parseFloat(aVal.replace(/[$,%]/g, ''));
const bNum = parseFloat(bVal.replace(/[$,%]/g, ''));
if (!isNaN(aNum) && !isNaN(bNum)) {
return direction === 'asc' ? aNum - bNum : bNum - aNum;
}
// Fall back to string comparison
return direction === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
});
// Re-append sorted rows
sorted.forEach(function (row) {
tbody.appendChild(row);
});
} catch (err) {
console.error('[main] sortTable error:', err);
}
}
/* ============================================================
3. Tab Switching
============================================================ */
/**
* Wire up tab buttons (elements with class .tab inside .tab-bar)
* to show / hide corresponding tab panels. The active tab gets
* class .active and the associated panel (referenced by
* data-tab attribute matching an id) receives .active.
*/
function initTabSwitching() {
try {
const tabBars = document.querySelectorAll('.tab-bar');
if (!tabBars.length) return;
tabBars.forEach(function (tabBar) {
const tabs = tabBar.querySelectorAll('.tab');
if (!tabs.length) return;
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
switchTab(tabBar, tab);
});
});
});
} catch (err) {
console.error('[main] Tab switching init error:', err);
}
}
/**
* Activate a specific tab within a tab bar, deactivating siblings.
*/
function switchTab(tabBar, activeTab) {
try {
const targetId = activeTab.getAttribute('data-tab');
if (!targetId) return;
// Deactivate all tabs in this bar
const allTabs = tabBar.querySelectorAll('.tab');
allTabs.forEach(function (t) {
t.classList.remove('active');
});
// Activate clicked tab
activeTab.classList.add('active');
// Deactivate all panels, then activate the target
const panels = document.querySelectorAll('[data-tab-panel]');
panels.forEach(function (panel) {
const isMatch = panel.getAttribute('data-tab-panel') === targetId;
panel.style.display = isMatch ? 'block' : 'none';
});
} catch (err) {
console.error('[main] switchTab error:', err);
}
}
/* ============================================================
4. Back to Top Button
============================================================ */
/**
* Create and manage a "back to top" button that appears when the
* user scrolls down past 400 px. Clicking it smooth-scrolls to
* the top of the page.
*/
function initBackToTop() {
try {
// Check if button already exists
let btn = document.getElementById('back-to-top');
if (!btn) {
btn = document.createElement('button');
btn.id = 'back-to-top';
btn.setAttribute('aria-label', 'Back to top');
btn.title = 'Back to top';
btn.innerHTML = '↑'; // Up arrow
document.body.appendChild(btn);
}
// Style the button (minimal inline — primary styles in style.css optional)
btn.style.position = 'fixed';
btn.style.bottom = '24px';
btn.style.right = '24px';
btn.style.width = '44px';
btn.style.height = '44px';
btn.style.borderRadius = '50%';
btn.style.backgroundColor = '#d4af37';
btn.style.color = '#131722';
btn.style.border = 'none';
btn.style.fontSize = '1.25rem';
btn.style.cursor = 'pointer';
btn.style.boxShadow = '0 4px 12px rgba(212,175,55,0.3)';
btn.style.transition = 'opacity 0.3s, transform 0.3s';
btn.style.opacity = '0';
btn.style.transform = 'translateY(12px)';
btn.style.zIndex = '999';
btn.style.display = 'flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.lineHeight = '1';
// Show / hide on scroll
let isVisible = false;
window.addEventListener('scroll', function () {
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
if (scrollY > 400 && !isVisible) {
isVisible = true;
btn.style.opacity = '1';
btn.style.transform = 'translateY(0)';
btn.style.pointerEvents = 'auto';
} else if (scrollY <= 400 && isVisible) {
isVisible = false;
btn.style.opacity = '0';
btn.style.transform = 'translateY(12px)';
btn.style.pointerEvents = 'none';
}
});
// Scroll to top on click
btn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
} catch (err) {
console.error('[main] Back to top init error:', err);
}
}
/* ============================================================
5. Smooth Scroll for Anchor Links
============================================================ */
/**
* Intercept clicks on same-page anchor links (href starting with
* '#') and smoothly scroll to the target element instead of the
* default jump behaviour.
*/
function initSmoothScroll() {
try {
document.addEventListener('click', function (e) {
const link = e.target.closest('a[href^="#"]');
if (!link) return;
const href = link.getAttribute('href');
if (href === '#' || href === '') return;
const target = document.querySelector(href);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
} catch (err) {
console.error('[main] Smooth scroll init error:', err);
}
}
/* ============================================================
6. Number Formatting Helpers
============================================================ */
/**
* Format a number with commas and fixed decimal places.
*
* @param {number} value - The number to format.
* @param {number} [decimals=2] - Decimal places to show.
* @returns {string} Formatted string or '—' on failure.
*/
function formatNumber(value, decimals) {
try {
if (value === null || value === undefined || isNaN(value)) return '—';
const dec = decimals !== undefined ? decimals : 2;
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: dec,
maximumFractionDigits: dec,
});
} catch (_) {
return String(value);
}
}
/**
* Format a value as a percentage with sign.
*
* @param {number} value - Percentage value (e.g. 3.45).
* @param {number} [decimals=2] - Decimal places.
* @returns {string} e.g. '+3.45%' or '-1.20%'.
*/
function formatPercent(value, decimals) {
try {
if (value === null || value === undefined || isNaN(value)) return '—';
const dec = decimals !== undefined ? decimals : 2;
const sign = value >= 0 ? '+' : '';
return sign + Number(value).toFixed(dec) + '%';
} catch (_) {
return String(value);
}
}
/**
* Format a market-cap number in human-readable form (K, M, B, T).
*
* @param {number} value - The raw value.
* @returns {string} e.g. '1.45B' or '892.3M'.
*/
function formatMarketCap(value) {
try {
if (value === null || value === undefined || isNaN(value)) return '—';
if (value >= 1e12) return (value / 1e12).toFixed(2) + 'T';
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B';
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M';
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K';
return Number(value).toFixed(2);
} catch (_) {
return String(value);
}
}
/**
* Format a volume number compactly (K, M, B).
*
* @param {number} value - Raw volume.
* @returns {string} e.g. '12.5M' or '1.2B'.
*/
function formatVolume(value) {
try {
return formatMarketCap(value);
} catch (_) {
return String(value);
}
}
/**
* Shortcut: format a number with commas, 2 decimals, and a
* currency prefix. Keeps it consistent across the UI.
*
* @param {number} value - Price value.
* @param {string} [prefix='$'] - Currency symbol.
* @returns {string} e.g. '$1,234.56'.
*/
function formatCurrency(value, prefix) {
try {
const pfx = prefix !== undefined ? prefix : '$';
return pfx + formatNumber(value, 2);
} catch (_) {
return String(value);
}
}
// ============================================================
// WATCHLIST — localStorage-based favorites tracker
// ============================================================
const Watchlist = {
STORAGE_KEY: 'pricify_watchlist',
get() {
try {
return JSON.parse(localStorage.getItem(this.STORAGE_KEY)) || [];
} catch(e) { return []; }
},
add(id, type, name, symbol) {
const list = this.get();
if (!list.some(item => item.id === id && item.type === type)) {
list.push({ id, type, name, symbol, addedAt: Date.now() });
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(list));
}
this.updateUI();
},
remove(id, type) {
const list = this.get().filter(item => !(item.id === id && item.type === type));
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(list));
this.updateUI();
},
has(id, type) {
return this.get().some(item => item.id === id && item.type === type);
},
toggle(btn) {
const id = btn.dataset.watchId;
const type = btn.dataset.watchType;
const name = btn.dataset.watchName || '';
const symbol = btn.dataset.watchSymbol || '';
if (this.has(id, type)) {
this.remove(id, type);
btn.classList.remove('watchlist--active');
btn.innerHTML = '☆';
btn.title = 'Add to Watchlist';
} else {
this.add(id, type, name, symbol);
btn.classList.add('watchlist--active');
btn.innerHTML = '★';
btn.title = 'Remove from Watchlist';
}
// Dispatch event so other UI can react
document.dispatchEvent(new CustomEvent('watchlist:change'));
},
updateUI() {
// Update all watchlist buttons on the page
document.querySelectorAll('[data-watch-id]').forEach(btn => {
const id = btn.dataset.watchId;
const type = btn.dataset.watchType;
if (this.has(id, type)) {
btn.classList.add('watchlist--active');
btn.innerHTML = '★';
btn.title = 'Remove from Watchlist';
} else {
btn.classList.remove('watchlist--active');
btn.innerHTML = '☆';
btn.title = 'Add to Watchlist';
}
});
// Update watchlist dropdown/counter if it exists
const counter = document.getElementById('watchlist-counter');
if (counter) counter.textContent = this.get().length;
},
renderDropdown() {
const items = this.get();
let html = '';
items.slice(0, 10).forEach(item => {
const name = item.name || item.id;
const symbol = item.symbol || '';
const link = item.type === 'crypto' ? 'crypto-detail.php?id=' + item.id :
item.type === 'stock' ? 'stock-detail.php?symbol=' + item.id : '#';
html += '' +
'' + name + '' +
'' + symbol + '' +
'';
});
if (!html) html = ' No items in watchlist ';
return html;
}
};
// Initialize watchlist buttons on page load
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-watch-id]');
if (btn) {
e.preventDefault();
Watchlist.toggle(btn);
}
});
// ============================================================
// CSV EXPORT
// ============================================================
function exportTableToCSV(tableId, filename) {
const table = document.getElementById(tableId);
if (!table) return;
const rows = table.querySelectorAll('tr');
let csv = [];
rows.forEach(row => {
const cols = row.querySelectorAll('td, th');
const rowData = [];
cols.forEach(col => {
// Clean text: remove icons, images, etc.
let text = col.textContent.trim().replace(/\s+/g, ' ');
// Escape quotes
if (text.includes(',') || text.includes('"')) {
text = '"' + text.replace(/"/g, '""') + '"';
}
rowData.push(text);
});
csv.push(rowData.join(','));
});
const csvContent = csv.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename || 'marketpulse-export.csv';
link.click();
URL.revokeObjectURL(link.href);
}
// Add export buttons listener
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-export-csv]');
if (btn) {
e.preventDefault();
const tableId = btn.dataset.exportCsv;
const filename = btn.dataset.exportFilename || 'export.csv';
exportTableToCSV(tableId, filename);
}
});
// ============================================================
// CURRENCY CONVERTER
// ============================================================
function initCurrencyConverter() {
const form = document.getElementById('converter-form');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
const amount = parseFloat(document.getElementById('conv-amount').value) || 1;
const from = document.getElementById('conv-from').value;
const to = document.getElementById('conv-to').value;
const resultEl = document.getElementById('conv-result');
const rateEl = document.getElementById('conv-rate');
resultEl.innerHTML = ' Converting...';
// For crypto-to-crypto or crypto-to-fiat, use CoinGecko
const isCryptoFrom = ['BTC','ETH','SOL','XRP','ADA','USDT','USDC','BNB','DOGE'].includes(from);
const isCryptoTo = ['BTC','ETH','SOL','XRP','ADA','USDT','USDC','BNB','DOGE'].includes(to);
let apiUrl = '';
if (isCryptoFrom && isCryptoTo) {
// Crypto to crypto - get both in USD
apiUrl = '/api/proxy.php?action=crypto-convert&from=' + from + '&to=' + to + '&amount=' + amount;
} else if (isCryptoFrom || isCryptoTo) {
// Crypto to fiat or fiat to crypto
const crypto = isCryptoFrom ? from : to;
const fiat = isCryptoFrom ? to : from;
apiUrl = '/api/proxy.php?action=crypto-convert&from=' + from + '&to=' + to + '&amount=' + amount;
} else {
// Fiat to fiat
apiUrl = '/api/proxy.php?action=forex-convert&from=' + from + '&to=' + to + '&amount=' + amount;
}
fetch(apiUrl)
.then(r => r.json())
.then(data => {
if (data.success && data.converted !== undefined) {
const formatted = new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(data.converted);
resultEl.innerHTML = '' + amount + ' ' + from + ' = ' + formatted + ' ' + to + '';
if (data.rate) {
rateEl.innerHTML = '1 ' + from + ' = ' + data.rate + ' ' + to;
}
} else {
resultEl.innerHTML = 'Conversion failed. Please try again.';
}
})
.catch(() => {
resultEl.innerHTML = 'Error fetching conversion rate.';
});
});
}
// ============================================================
// PWA SERVICE WORKER REGISTRATION
// ============================================================
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err) {
console.log('SW registration failed: ', err);
});
});
}
// ============================================================
// WATCHLIST DROPDOWN TOGGLE (for header)
// ============================================================
document.addEventListener('DOMContentLoaded', function() {
// Initialize watchlist UI
Watchlist.updateUI();
// Watchlist dropdown toggle
const wlToggle = document.getElementById('watchlist-toggle');
const wlDropdown = document.getElementById('watchlist-dropdown');
if (wlToggle && wlDropdown) {
wlToggle.addEventListener('click', function(e) {
e.stopPropagation();
wlDropdown.classList.toggle('watchlist--open');
if (wlDropdown.classList.contains('watchlist--open')) {
wlDropdown.innerHTML = Watchlist.renderDropdown();
}
});
document.addEventListener('click', function() {
if (wlDropdown) wlDropdown.classList.remove('watchlist--open');
});
}
// Currency converter init
initCurrencyConverter();
}); |
|---|