/** * 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 elements that have the * class .data-table. Clicking a rows. * * CSS classes .sort-asc and .sort-desc are applied to the * active
toggles ascending / * descending sort for that column and re-orders the
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(); });