Sadeep Sachintha
chore: rebrand project as a premium product of SS Labz across UI and documentation
5cb7ca4
Raw
History Blame Contribute Delete
21.9 kB
/**
* ========================================================================
* CEYLON GOLD RATE - FRONTEND LOGIC & REAL-TIME API CONNECTOR
* A product of SS Labz.
* Handles responsive dashboards, interactive cost calculators,
* FAQ accordions, persistent styling, and direct backend API integration
* for scraping live prices.
* ========================================================================
*/
document.addEventListener('DOMContentLoaded', () => {
// --- State Management ---
let goldRates = {
24: 48150, // Fallback Defaults in LKR per Gram
22: 44150,
21: 42140,
18: 36112.5
};
let yesterdayRates = {
24: 48090,
22: 44080,
21: 42080,
18: 36067.5
};
// Store actual scraped history
let realHistoryData = null;
let activeDisplayUnit = 'gram'; // 'gram' or 'sovereign'
let activeChartPeriod = '7d'; // '7d' or '30d' or '1y'
let historyChart = null;
// --- DOM Elements ---
const themeToggleBtn = document.getElementById('theme-toggle-btn');
const refreshBtn = document.getElementById('refresh-btn');
const lastUpdatedText = document.getElementById('last-updated-text');
const connectionStatus = document.getElementById('connection-status');
// Spotlight Elements
const sovPrice22k = document.getElementById('sov-price-22k');
const gramPrice22k = document.getElementById('gram-price-22k');
const pct22k = document.getElementById('pct-22k');
const trend22k = document.getElementById('trend-22k');
const prevSov22k = document.getElementById('prev-sov-22k');
const diffSov22k = document.getElementById('diff-sov-22k');
const sovPrice24k = document.getElementById('sov-price-24k');
const gramPrice24k = document.getElementById('gram-price-24k');
const pct24k = document.getElementById('pct-24k');
const trend24k = document.getElementById('trend-24k');
const prevSov24k = document.getElementById('prev-sov-24k');
const diffSov24k = document.getElementById('diff-sov-24k');
// Comprehensive Pricing Tabs
const toggleGramBtn = document.getElementById('toggle-gram');
const toggleSovereignBtn = document.getElementById('toggle-sovereign');
// Calculator Elements
const calcWeight = document.getElementById('calc-weight');
const calcUnit = document.getElementById('calc-unit');
const calcKarat = document.getElementById('calc-karat');
const calcCharges = document.getElementById('calc-charges');
const outBasePrice = document.getElementById('out-base-price');
const outChargePct = document.getElementById('out-charge-pct');
const outChargeCost = document.getElementById('out-charge-cost');
const outTotalPrice = document.getElementById('out-total-price');
const weightUnitLabel = document.getElementById('weight-unit-label');
// Chart Filter Tabs
const chart7dBtn = document.getElementById('chart-7d');
const chart30dBtn = document.getElementById('chart-30d');
const chart1yBtn = document.getElementById('chart-1y');
// --- Helper Functions ---
const formatLKR = (val) => {
return 'Rs. ' + Math.round(val).toLocaleString('en-US');
};
const formatPercent = (val) => {
const sign = val >= 0 ? '+' : '';
return `${sign}${val.toFixed(2)}%`;
};
const getGramsFromWeight = (weight, unit) => {
return unit === 'sovereign' ? weight * 8 : weight;
};
// --- Dynamic UI Rendering ---
const updateDashboardPrices = () => {
// 22K Sovereign Calculation
const priceGram22 = goldRates[22];
const priceSov22 = priceGram22 * 8;
const prevSov22 = yesterdayRates[22] * 8;
const diff22 = priceSov22 - prevSov22;
const pctDiff22 = (diff22 / prevSov22) * 100;
sovPrice22k.textContent = formatLKR(priceSov22);
gramPrice22k.textContent = `${formatLKR(priceGram22)} per gram`;
prevSov22k.textContent = formatLKR(prevSov22);
diffSov22k.textContent = `${diff22 >= 0 ? '+' : ''}${formatLKR(diff22)}`;
pct22k.textContent = formatPercent(pctDiff22);
// Adjust trend badges
if (diff22 >= 0) {
trend22k.className = 'trend-badge up';
trend22k.innerHTML = `<i class="fa-solid fa-caret-up"></i> <span class="trend-percent" id="pct-22k">${formatPercent(pctDiff22)}</span>`;
} else {
trend22k.className = 'trend-badge down';
trend22k.innerHTML = `<i class="fa-solid fa-caret-down"></i> <span class="trend-percent" id="pct-22k">${formatPercent(pctDiff22)}</span>`;
}
// 24K Sovereign Calculation
const priceGram24 = goldRates[24];
const priceSov24 = priceGram24 * 8;
const prevSov24 = yesterdayRates[24] * 8;
const diff24 = priceSov24 - prevSov24;
const pctDiff24 = (diff24 / prevSov24) * 100;
sovPrice24k.textContent = formatLKR(priceSov24);
gramPrice24k.textContent = `${formatLKR(priceGram24)} per gram`;
prevSov24k.textContent = formatLKR(prevSov24);
diffSov24k.textContent = `${diff24 >= 0 ? '+' : ''}${formatLKR(diff24)}`;
pct24k.textContent = formatPercent(pctDiff24);
if (diff24 >= 0) {
trend24k.className = 'trend-badge up';
trend24k.innerHTML = `<i class="fa-solid fa-caret-up"></i> <span class="trend-percent" id="pct-24k">${formatPercent(pctDiff24)}</span>`;
} else {
trend24k.className = 'trend-badge down';
trend24k.innerHTML = `<i class="fa-solid fa-caret-down"></i> <span class="trend-percent" id="pct-24k">${formatPercent(pctDiff24)}</span>`;
}
// Update grid
updateComprehensiveGrid();
};
const updateComprehensiveGrid = () => {
const karats = [24, 22, 21, 18];
const multiplier = activeDisplayUnit === 'sovereign' ? 8 : 1;
const labelText = activeDisplayUnit === 'sovereign' ? 'Per Sovereign' : 'Per Gram';
karats.forEach(k => {
const price = goldRates[k] * multiplier;
document.getElementById(`grid-price-${k}k`).textContent = formatLKR(price);
document.getElementById(`grid-label-${k}k`).textContent = labelText;
});
};
// --- Interactive Gold Calculator ---
const runCalculator = () => {
const weight = parseFloat(calcWeight.value) || 0;
const unit = calcUnit.value;
const karat = parseInt(calcKarat.value);
const chargesPct = parseFloat(calcCharges.value) || 0;
const grams = getGramsFromWeight(weight, unit);
const ratePerGram = goldRates[karat];
const baseGoldCost = grams * ratePerGram;
const makingChargesCost = baseGoldCost * (chargesPct / 100);
const totalEstimatedCost = baseGoldCost + makingChargesCost;
outBasePrice.textContent = formatLKR(baseGoldCost);
outChargePct.textContent = chargesPct;
outChargeCost.textContent = formatLKR(makingChargesCost);
outTotalPrice.textContent = formatLKR(totalEstimatedCost);
};
// Calculator event listeners
[calcWeight, calcCharges].forEach(el => {
el.addEventListener('input', runCalculator);
});
[calcUnit, calcKarat].forEach(el => {
el.addEventListener('change', () => {
weightUnitLabel.textContent = calcUnit.value === 'sovereign' ? 'sov' : 'g';
runCalculator();
});
});
// --- Market Tiers Unit Switching ---
toggleGramBtn.addEventListener('click', () => {
activeDisplayUnit = 'gram';
toggleGramBtn.classList.add('active');
toggleGramBtn.setAttribute('aria-selected', 'true');
toggleSovereignBtn.classList.remove('active');
toggleSovereignBtn.setAttribute('aria-selected', 'false');
updateComprehensiveGrid();
});
toggleSovereignBtn.addEventListener('click', () => {
activeDisplayUnit = 'sovereign';
toggleSovereignBtn.classList.add('active');
toggleSovereignBtn.setAttribute('aria-selected', 'true');
toggleGramBtn.classList.remove('active');
toggleGramBtn.setAttribute('aria-selected', 'false');
updateComprehensiveGrid();
});
// --- Theme Management (Persistent Dark/Light) ---
const getTheme = () => {
return document.documentElement.getAttribute('data-theme') || 'dark';
};
const setTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('ceylon-gold-theme', theme);
if (historyChart) {
updateChartThemeConfig(theme);
}
};
// Initialize Theme
const savedTheme = localStorage.getItem('ceylon-gold-theme') || 'dark';
setTheme(savedTheme);
themeToggleBtn.addEventListener('click', () => {
const nextTheme = getTheme() === 'dark' ? 'light' : 'dark';
setTheme(nextTheme);
});
// --- FAQ Accordion Logic ---
const faqButtons = document.querySelectorAll('.faq-question-btn');
faqButtons.forEach(btn => {
btn.addEventListener('click', () => {
const faqItem = btn.parentElement;
const isOpen = faqItem.classList.contains('active');
document.querySelectorAll('.faq-item').forEach(item => {
item.classList.remove('active');
item.querySelector('.faq-question-btn').setAttribute('aria-expanded', 'false');
});
if (!isOpen) {
faqItem.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
});
});
// --- Dynamic / Scraped Historical Data Mapper ---
const getHistoricalData = (period, karat) => {
let labels = [];
let data = [];
const today = new Date();
// If we have actual real scraped history from our backend scraper
if (realHistoryData && realHistoryData[karat] && realHistoryData.dates) {
const rawDates = realHistoryData.dates;
const rawPrices = realHistoryData[karat];
if (period === '7d') {
// Slice last 7 business days and reverse so they appear chronological (past -> present)
labels = rawDates.slice(0, 7).reverse().map(d => formatDateLabel(d));
data = rawPrices.slice(0, 7).reverse();
} else if (period === '30d') {
// Slice 30 entries and reverse
labels = rawDates.slice(0, 20).reverse().map(d => formatDateLabel(d));
data = rawPrices.slice(0, 20).reverse();
} else if (period === '1y') {
// Show full available history (50 records)
labels = rawDates.slice(0, 50).reverse().map(d => formatDateLabel(d, true));
data = rawPrices.slice(0, 50).reverse();
}
return { labels, data };
}
// Simulative fallback generator (if backend scraping API is offline)
const currentPrice = goldRates[karat];
if (period === '7d') {
for (let i = 6; i >= 0; i--) {
const date = new Date(today);
date.setDate(today.getDate() - i);
labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
}
data = [currentPrice - 350, currentPrice - 100, currentPrice - 250, currentPrice - 30, currentPrice - 150, currentPrice - 58, currentPrice];
} else if (period === '30d') {
for (let i = 29; i >= 0; i -= 3) {
const date = new Date(today);
date.setDate(today.getDate() - i);
labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
}
let base = currentPrice - 980;
for (let i = 0; i < 10; i++) {
base += (Math.random() * 400 - 150);
if (i === 9) base = currentPrice;
data.push(Math.round(base));
}
} else if (period === '1y') {
labels = ['Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May'];
data = [currentPrice - 5100, currentPrice - 4700, currentPrice - 4200, currentPrice - 4500, currentPrice - 3800, currentPrice - 3200, currentPrice - 3400, currentPrice - 2600, currentPrice - 2100, currentPrice - 1200, currentPrice - 600, currentPrice];
}
return { labels, data };
};
// Standardizer for date labels (convert YYYY-MM-DD to "May 29")
const formatDateLabel = (dateStr, includeYear = false) => {
try {
const parts = dateStr.split('-');
if (parts.length !== 3) return dateStr;
const date = new Date(parts[0], parts[1] - 1, parts[2]);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: includeYear ? '2-digit' : undefined
});
} catch(e) {
return dateStr;
}
};
// --- Chart.js Rendering & Adaptations ---
const updateChartThemeConfig = (theme) => {
if (!historyChart) return;
const isDark = theme === 'dark';
const textColor = isDark ? '#9ca3af' : '#4b5563';
const gridColor = isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)';
historyChart.options.scales.x.ticks.color = textColor;
historyChart.options.scales.y.ticks.color = textColor;
historyChart.options.scales.x.grid.color = gridColor;
historyChart.options.scales.y.grid.color = gridColor;
historyChart.update();
};
const renderChart = () => {
const ctx = document.getElementById('goldHistoryChart').getContext('2d');
const activeKarat = parseInt(calcKarat.value);
const { labels, data } = getHistoricalData(activeChartPeriod, activeKarat);
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, 'rgba(229, 184, 59, 0.35)');
gradient.addColorStop(1, 'rgba(229, 184, 59, 0.0)');
const theme = getTheme();
const isDark = theme === 'dark';
const textColor = isDark ? '#9ca3af' : '#4b5563';
const gridColor = isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)';
const chartConfig = {
type: 'line',
data: {
labels: labels,
datasets: [{
label: `${activeKarat}K Gold Price (LKR/g)`,
data: data,
borderColor: '#e5b83b',
borderWidth: 3,
backgroundColor: gradient,
fill: true,
tension: 0.3,
pointBackgroundColor: '#e5b83b',
pointBorderColor: isDark ? '#11141a' : '#ffffff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#11141a' : '#ffffff',
titleColor: '#e5b83b',
bodyColor: isDark ? '#f3f4f6' : '#111827',
borderColor: 'rgba(229, 184, 59, 0.4)',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return `Price: Rs. ${context.parsed.y.toLocaleString('en-US')} / g`;
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { family: 'Inter', size: 10 }
}
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { family: 'Inter', size: 10 },
callback: function(value) {
return 'Rs.' + (value / 1000).toFixed(0) + 'k';
}
}
}
}
}
};
if (historyChart) {
historyChart.destroy();
}
historyChart = new Chart(ctx, chartConfig);
};
// Setup chart period click listeners
const handlePeriodChange = (btn, period) => {
[chart7dBtn, chart30dBtn, chart1yBtn].forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
activeChartPeriod = period;
renderChart();
};
chart7dBtn.addEventListener('click', () => handlePeriodChange(chart7dBtn, '7d'));
chart30dBtn.addEventListener('click', () => handlePeriodChange(chart30dBtn, '30d'));
chart1yBtn.addEventListener('click', () => handlePeriodChange(chart1yBtn, '1y'));
calcKarat.addEventListener('change', renderChart);
// --- Backend API rates integration ---
const loadRealRatesFromAPI = (isInitial = false) => {
if (refreshBtn.classList.contains('loading') && !isInitial) return;
// Visual loading trigger
refreshBtn.classList.add('loading');
refreshBtn.querySelector('i').className = 'fa-solid fa-arrows-rotate fa-spin';
lastUpdatedText.textContent = 'Fetching current Ceylon Gold indexes...';
// Show status feedback
connectionStatus.className = 'ticker-status';
connectionStatus.innerHTML = '<span class="pulse-dot" style="background-color: var(--warning); box-shadow: 0 0 8px var(--warning);"></span> <span>Connecting...</span>';
const fetchUrl = isInitial ? 'api.php?_=' + Date.now() : 'api.php?force=true&_=' + Date.now();
fetch(fetchUrl)
.then(response => {
if (!response.ok) throw new Error('API server HTTP error ' + response.status);
return response.json();
})
.then(data => {
if (data && data.rates) {
// Update state variables with scraped prices
Object.keys(goldRates).forEach(k => {
if (data.rates[k]) {
goldRates[k] = data.rates[k];
}
});
Object.keys(yesterdayRates).forEach(k => {
if (data.yesterday && data.yesterday[k]) {
yesterdayRates[k] = data.yesterday[k];
}
});
// Store historical vectors
if (data.history) {
realHistoryData = data.history;
}
// Update UI Prices
updateDashboardPrices();
runCalculator();
renderChart();
// Status success display
connectionStatus.className = 'ticker-status';
connectionStatus.innerHTML = '<span class="pulse-dot"></span> <span>Live Market Feed</span>';
// Show parsed timestamp
lastUpdatedText.textContent = `Last updated: ${data.last_updated} ${data.cached ? '(Cached)' : ''}`;
} else {
throw new Error('API returned malformed data');
}
})
.catch(error => {
console.warn('API fetch failed, falling back to simulated data: ', error);
// Keep default simulated engine active on local offline environments
if (!isInitial) {
// Slight random fluctuation fallback
Object.keys(goldRates).forEach(k => {
const fluctuationFactor = 1 + ((Math.random() * 0.45 - 0.15) / 100);
yesterdayRates[k] = goldRates[k];
goldRates[k] = Math.round(goldRates[k] * fluctuationFactor * 100) / 100;
});
updateDashboardPrices();
runCalculator();
renderChart();
}
// Show status feedback warning
connectionStatus.className = 'ticker-status';
connectionStatus.innerHTML = '<span class="pulse-dot" style="background-color: var(--danger); box-shadow: 0 0 8px var(--danger);"></span> <span>Demo Feed (Offline)</span>';
const now = new Date();
lastUpdatedText.textContent = `Last updated: Offline fallback at ${now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`;
})
.finally(() => {
// Remove loading indicators
refreshBtn.classList.remove('loading');
refreshBtn.querySelector('i').className = 'fa-solid fa-arrows-rotate';
});
};
// Hook refresh click to load real rates
refreshBtn.addEventListener('click', () => loadRealRatesFromAPI(false));
// --- Initial Boot Initialization ---
// Perform initial boot with API fetch
loadRealRatesFromAPI(true);
});