Update Harga Internasional

This commit is contained in:
2026-11-03 09:37:59 +07:00
parent 013cb7b728
commit c1370a26ca
10 changed files with 586 additions and 1 deletions
+294
View File
@@ -127,6 +127,62 @@
width: 100%;
}
}
.market-info-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: 0 0 20px;
padding: 0;
border: 1px solid var(--bs-secondary-color);
border-radius: 50%;
background: transparent;
color: var(--bs-secondary-color);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.market-info-button:hover, .market-info-button:focus-visible {
color: var(--bs-primary);
border-color: var(--bs-primary);
outline: 2px solid rgba(var(--bs-primary-rgb), .2);
outline-offset: 2px;
}
.international-provider-logo {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
.international-provider-logo[hidden] { display: none; }
.international-provider-logo img { object-fit: contain; }
.international-market-track {
display: flex;
gap: 1.25rem;
overflow-x: auto;
scroll-snap-type: none;
scrollbar-width: none;
padding: 2px 2px 16px;
}
.international-market-track::-webkit-scrollbar { display: none; }
.international-market-card {
flex: 0 0 calc((100% - 2.5rem) / 3);
min-width: 0;
scroll-snap-align: start;
}
@media (max-width: 991.98px) {
.international-market-card { flex-basis: calc((100% - 1.25rem) / 2); }
}
@media (max-width: 575.98px) {
.international-market-card { flex-basis: 100%; }
}
</style>
@endpush
@@ -358,12 +414,250 @@
</div>
</div>
</div>
<section class="mt-4" aria-labelledby="international-market-title" id="international-market">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2">
<h5 id="international-market-title" class="mb-0">Harga Komoditas Internasional</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-market-prev aria-label="Harga sebelumnya">&larr;</button>
<button type="button" class="btn btn-sm btn-icon btn-outline-secondary" data-market-pause aria-pressed="false" aria-label="Jeda slider" title="Jeda slider"><i class="icon-base ti tabler-player-pause" aria-hidden="true"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-market-next aria-label="Harga berikutnya">&rarr;</button>
</div>
</div>
<p class="text-body-secondary small mb-2">Setiap kartu menampilkan satu komoditas dari satu sumber. Periode dan jenis harga mengikuti penyedia; tidak mengikuti filter wilayah.</p>
<p class="small text-body-secondary" data-market-status role="status">Memuat harga internasional...</p>
<div class="international-market-track" tabindex="0" role="region" aria-roledescription="carousel" aria-label="Harga komoditas menurut sumber"></div>
<template id="international-market-card">
<article class="card card-border-shadow-info international-market-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-2">
<h6 class="mb-0" data-name></h6>
<button type="button" class="market-info-button" data-market-info data-update-info aria-label="Informasi pembaruan harga" aria-expanded="false">!</button>
</div>
<div class="small">Sumber: <a data-source target="_blank" rel="noopener noreferrer"></a></div>
</div>
<span class="international-provider-logo flex-shrink-0" data-provider-logo-wrap hidden>
<img data-provider-logo width="32" height="32" alt="" decoding="async">
</span>
</div>
<div class="pasar-kpi-value" data-price></div>
<div class="small text-body-secondary mt-1" data-unit></div>
<div class="d-flex align-items-center gap-2 mt-3 pt-3 border-top">
<div class="fw-semibold fs-5" data-rupiah></div>
<button type="button" class="market-info-button" data-market-info data-fx-info aria-label="Informasi kurs konversi" aria-expanded="false">!</button>
</div>
<p class="small text-body-secondary mt-2 mb-2" data-description></p>
<div class="small text-body-secondary" data-period></div>
</div>
</article>
</template>
</section>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
const marketSection = document.getElementById('international-market');
const marketTrack = marketSection.querySelector('.international-market-track');
const marketStatus = marketSection.querySelector('[data-market-status]');
const pauseButton = marketSection.querySelector('[data-market-pause]');
let activeMarketInfo = null;
function closeMarketInfo() {
if (!activeMarketInfo) return;
activeMarketInfo.button.setAttribute('aria-expanded', 'false');
activeMarketInfo.popover.dispose();
activeMarketInfo = null;
}
document.addEventListener('click', event => {
const button = event.target.closest('[data-market-info]');
if (button && marketSection.contains(button)) {
const same = activeMarketInfo?.button === button;
closeMarketInfo();
if (same) return;
const popover = new bootstrap.Popover(button, {
container: 'body', placement: 'auto', trigger: 'manual', html: false,
title: button.hasAttribute('data-fx-info') ? 'Kurs konversi' : 'Pembaruan harga',
content: button.dataset.info || 'Informasi belum tersedia.'
});
popover.show();
button.setAttribute('aria-expanded', 'true');
activeMarketInfo = { button, popover };
} else if (!event.target.closest('.popover')) closeMarketInfo();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeMarketInfo();
});
marketTrack.addEventListener('focusout', event => {
if (activeMarketInfo?.button === event.target) closeMarketInfo();
});
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let marketPaused = reducedMotion.matches;
let marketSignature = '';
const quoteDateFormat = new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium', timeStyle: 'long', timeZone: 'Asia/Jakarta'
});
const rupiahFormat = new Intl.NumberFormat('id-ID', {
style: 'currency', currency: 'IDR', maximumFractionDigits: 2
});
function syncMarketPause() {
const label = marketPaused ? 'Putar slider' : 'Jeda slider';
pauseButton.querySelector('i').className = 'icon-base ti ' + (marketPaused ? 'tabler-player-play' : 'tabler-player-pause');
pauseButton.setAttribute('aria-label', label);
pauseButton.title = label;
pauseButton.setAttribute('aria-pressed', String(marketPaused));
}
syncMarketPause();
pauseButton.addEventListener('click', () => { marketPaused = !marketPaused; syncMarketPause(); });
let marketCycleWidth = 0;
let lastMarketFrame = null;
function measureMarketCycle() {
const first = marketTrack.firstElementChild;
const duplicate = marketTrack.querySelector('[data-market-clone]');
marketCycleWidth = first && duplicate ? duplicate.offsetLeft - first.offsetLeft : 0;
}
function slideMarket(direction) {
if (!marketTrack.firstElementChild) return;
const step = marketTrack.firstElementChild.getBoundingClientRect().width + 20;
if (direction < 0 && marketTrack.scrollLeft < step && marketCycleWidth) {
marketTrack.scrollLeft += marketCycleWidth;
}
marketTrack.scrollLeft += step * direction;
}
marketSection.querySelector('[data-market-prev]').addEventListener('click', () => slideMarket(-1));
marketSection.querySelector('[data-market-next]').addEventListener('click', () => slideMarket(1));
window.addEventListener('resize', measureMarketCycle);
function animateMarket(timestamp) {
const elapsed = lastMarketFrame === null ? 0 : Math.min(timestamp - lastMarketFrame, 64);
lastMarketFrame = timestamp;
if (marketCycleWidth > 0) {
if (!marketPaused && !document.hidden && !marketTrack.contains(document.activeElement)) {
// Keep subpixel progress independently: scrollLeft may round tiny per-frame changes.
marketScrollPosition += elapsed * 0.028;
marketTrack.scrollLeft = marketScrollPosition;
} else {
marketScrollPosition = marketTrack.scrollLeft;
}
if (marketTrack.scrollLeft >= marketCycleWidth) {
marketScrollPosition = marketTrack.scrollLeft - marketCycleWidth;
marketTrack.scrollLeft = marketScrollPosition;
}
}
window.requestAnimationFrame(animateMarket);
}
let marketScrollPosition = 0;
marketTrack.addEventListener('scroll', () => {
if (Math.abs(marketTrack.scrollLeft - marketScrollPosition) > 2) marketScrollPosition = marketTrack.scrollLeft;
}, { passive: true });
window.requestAnimationFrame(animateMarket);
async function refreshInternationalPrices() {
try {
const response = await fetch(@json(route('dashboard.pasar.international')), {
headers: { 'Accept': 'application/json' }, credentials: 'same-origin',
signal: AbortSignal.timeout(25000)
});
if (!response.ok) throw new Error('Harga tidak tersedia');
const payload = await response.json();
if (!Array.isArray(payload.quotes)) throw new Error('Data tidak valid');
const quotes = payload.quotes.filter(quote => typeof quote.price === 'number' && Number.isFinite(quote.price) && quote.price > 0);
const signature = JSON.stringify([quotes, payload.exchange_rate]);
if (signature !== marketSignature) {
const previousScroll = marketTrack.scrollLeft;
const fragment = document.createDocumentFragment();
quotes.forEach(quote => {
const card = document.getElementById('international-market-card').content.cloneNode(true);
card.querySelector('[data-name]').textContent = quote.name;
card.querySelector('[data-update-info]').dataset.info = quote.update_info || (quote.frequency === 'monthly'
? 'IMF menyediakan harga acuan rata-rata bulanan. Data terbaru diperiksa setiap hari. Periode harga tercantum di kartu.'
: 'Harga Yahoo Finance diperiksa setiap 5 menit. Data dapat tertunda mengikuti penyedia dan jam perdagangan.');
const rate = payload.exchange_rate;
let rateInfo = 'Kurs USD/IDR belum tersedia. Harga asli tetap ditampilkan.';
if (rate && typeof rate.value === 'number') {
rateInfo = '1 USD = ' + rupiahFormat.format(rate.value) + '. Sumber: ' + rate.source + '.';
const rateDate = new Date(rate.quoted_at);
if (!Number.isNaN(rateDate.getTime())) rateInfo += ' Waktu kurs: ' + quoteDateFormat.format(rateDate) + '.';
if (rate.stale) rateInfo += ' Menggunakan kurs tersimpan.';
if (quote.frequency === 'monthly') rateInfo += ' Konversi memakai kurs ini, bukan kurs historis bulan harga tersebut.';
}
card.querySelector('[data-fx-info]').dataset.info = rateInfo;
const providerLogos = {
yahoo: @json(asset('assets/img/providers/yahoo-finance.ico')),
imf: @json(asset('assets/img/providers/imf.ico')),
alpha: @json(asset('assets/img/providers/alpha-vantage.ico'))
};
const logoUrl = providerLogos[(quote.id || '').split(':')[0]];
if (logoUrl) {
const logo = card.querySelector('[data-provider-logo]');
logo.src = logoUrl;
logo.alt = 'Logo ' + quote.source;
card.querySelector('[data-provider-logo-wrap]').hidden = false;
}
const source = card.querySelector('[data-source]');
source.textContent = quote.source;
if (quote.source_url?.startsWith('https://')) source.href = quote.source_url;
card.querySelector('[data-price]').textContent = quote.price.toLocaleString('id-ID', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
card.querySelector('[data-unit]').textContent = quote.unit;
card.querySelector('[data-rupiah]').textContent = typeof quote.price_idr_per_kg === 'number'
? rupiahFormat.format(quote.price_idr_per_kg) + ' / kg' : 'Konversi rupiah belum tersedia';
card.querySelector('[data-description]').textContent = quote.description;
let period = '-';
if (quote.frequency === 'monthly' && /^\d{4}-\d{2}$/.test(quote.period || '')) {
period = 'Rata-rata ' + new Intl.DateTimeFormat('id-ID', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(quote.period + '-01T00:00:00Z'));
} else if (quote.period) {
const date = new Date(quote.period);
if (!Number.isNaN(date.getTime())) period = 'Waktu harga: ' + quoteDateFormat.format(date);
}
card.querySelector('[data-period]').textContent = period + (quote.stale ? ' · Data tersimpan' : '');
fragment.append(card);
});
closeMarketInfo();
marketTrack.replaceChildren(fragment);
if (quotes.length > 1) {
// Repeat the cards so the end joins the start without a visible rewind.
const originals = [...marketTrack.children];
[...originals, ...originals].forEach(original => {
const clone = original.cloneNode(true);
clone.dataset.marketClone = 'true';
marketTrack.append(clone);
});
}
measureMarketCycle();
marketScrollPosition = marketCycleWidth ? previousScroll % marketCycleWidth : 0;
marketTrack.scrollLeft = marketScrollPosition;
marketSignature = signature;
}
marketStatus.textContent = quotes.length
? '' : 'Harga belum tersedia. Pembaruan akan dicoba kembali otomatis.';
marketStatus.hidden = quotes.length > 0;
[pauseButton, ...marketSection.querySelectorAll('[data-market-prev], [data-market-next]')].forEach(button => button.disabled = quotes.length < 2);
} catch (error) {
marketStatus.hidden = false;
marketStatus.textContent = marketTrack.childElementCount
? 'Pembaruan gagal. Menampilkan harga sebelumnya dan akan mencoba kembali.'
: 'Harga belum dapat dimuat. Akan dicoba kembali otomatis.';
} finally {
window.setTimeout(() => {
if (!document.hidden) refreshInternationalPrices();
else waitForVisible();
}, 60000);
}
}
function waitForVisible() {
document.addEventListener('visibilitychange', function onVisible() {
if (document.hidden) return;
document.removeEventListener('visibilitychange', onVisible);
refreshInternationalPrices();
});
}
refreshInternationalPrices();
const wilayah = @json($wilayah);
const marketCharts = @json($marketCharts);
const chartDefinitions = @json($chartDefinitions);