forked from izu/student-web-if-development-kit
673 lines
24 KiB
JavaScript
673 lines
24 KiB
JavaScript
// --- Helper Fungsi ---
|
||
|
||
/**
|
||
* Helper untuk menerjemahkan string berdasarkan key dari localization.js.
|
||
* Menggunakan fallback jika key tidak ditemukan atau localization.js tidak aktif.
|
||
*/
|
||
function t(key, fallback) {
|
||
if (window.t) {
|
||
const val = window.t(key);
|
||
if (val !== key) return val;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/**
|
||
* Mendapatkan bahasa aktif dari localStorage atau default 'id'.
|
||
* @returns {'id'|'en'} Kode bahasa aktif
|
||
*/
|
||
function getLang() {
|
||
return localStorage.getItem('if_untan_lang') || 'id';
|
||
}
|
||
|
||
/**
|
||
* Mengambil teks dari field bilingual JSON { id: '...', en: '...' }.
|
||
* Jika field bukan objek (string lama / belum diupdate), kembalikan apa adanya.
|
||
* @param {string|Object} field - Field data dari JSON
|
||
* @returns {string} Teks sesuai bahasa aktif
|
||
*/
|
||
function loc(field) {
|
||
if (!field) return '';
|
||
if (typeof field === 'object' && field !== null) {
|
||
const lang = getLang();
|
||
return field[lang] || field['id'] || '';
|
||
}
|
||
return field;
|
||
}
|
||
|
||
/**
|
||
* Normalisasi nomor telepon ke format standard e.g. 628...
|
||
* @param {string} phone - Nomor telepon mentah
|
||
* @returns {string} Nomor telepon ternormalisasi
|
||
*/
|
||
function normalizePhone(phone) {
|
||
let digits = String(phone || '').replace(/\D/g, '');
|
||
if (digits.startsWith('0')) {
|
||
digits = `62${digits.slice(1)}`;
|
||
} else if (digits.startsWith('8')) {
|
||
digits = `62${digits}`;
|
||
}
|
||
return digits;
|
||
}
|
||
|
||
/**
|
||
* Format nomor telepon untuk tampilan UI
|
||
* @param {string} phone - Nomor telepon mentah
|
||
* @returns {string} Nomor telepon terformat (e.g. +62 8xx-xxxx-xxxx)
|
||
*/
|
||
function formatPhoneForDisplay(phone) {
|
||
const norm = normalizePhone(phone);
|
||
if (!norm) return '-';
|
||
return `+${norm.slice(0, 2)} ${norm.slice(2, 5)}-${norm.slice(5, 9)}-${norm.slice(9)}`;
|
||
}
|
||
|
||
/**
|
||
* Memvalidasi apakah nomor telepon valid untuk digunakan di WhatsApp.
|
||
* @param {string} phone - Nomor telepon mentah
|
||
* @returns {boolean} True jika nomor valid (seluler Indonesia 10-15 digit)
|
||
*/
|
||
function isValidWhatsAppNumber(phone) {
|
||
const norm = normalizePhone(phone);
|
||
return norm.startsWith('628') && norm.length >= 10 && norm.length <= 15;
|
||
}
|
||
|
||
/**
|
||
* Enkripsi nomor telepon menggunakan Base64 untuk keamanan kontak.
|
||
* @param {string} phone - Nomor telepon ternormalisasi
|
||
* @returns {string} Nomor telepon terenkripsi Base64
|
||
*/
|
||
function encodePhone(phone) {
|
||
return btoa(normalizePhone(phone));
|
||
}
|
||
|
||
/**
|
||
* Dekripsi nomor telepon dari Base64.
|
||
* @param {string} encoded - Nomor telepon terenkripsi Base64
|
||
* @returns {string} Nomor telepon terdekripsi
|
||
*/
|
||
function decodePhone(encoded) {
|
||
try {
|
||
return atob(encoded);
|
||
} catch (error) {
|
||
console.error('Gagal mendekripsi nomor telepon:', error);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Merender daftar staf pendukung ke dalam container HTML.
|
||
* @param {Array<Object>} staffs - Array data staf pendukung
|
||
*/
|
||
export function renderStaffCards(staffs) {
|
||
const container = document.getElementById('staff-container');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = '';
|
||
|
||
if (!staffs || staffs.length === 0) {
|
||
container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_staff', 'Data staf pendukung tidak tersedia saat ini.')}</p>`;
|
||
return;
|
||
}
|
||
|
||
staffs.forEach((staff, index) => {
|
||
const {
|
||
name,
|
||
role,
|
||
image,
|
||
location,
|
||
phone,
|
||
contact_consent: contactConsent,
|
||
schedule,
|
||
tasks
|
||
} = staff;
|
||
|
||
const roleText = loc(role);
|
||
const locationText = loc(location);
|
||
|
||
const scheduleHTML = schedule.map(sched =>
|
||
`<div class="schedule-item">` +
|
||
`<span class="s-day">${loc(sched.day)}</span>` +
|
||
`<span class="s-time">${sched.time} <span class="s-break">| ${loc(sched.break)}</span></span>` +
|
||
`</div>`
|
||
).join('');
|
||
|
||
const tasksHTML = tasks.map(task => `<li>${loc(task)}</li>`).join('');
|
||
const isPhoneValid = isValidWhatsAppNumber(phone);
|
||
const encodedPhone = isPhoneValid ? encodePhone(phone) : '';
|
||
const displayPhone = isPhoneValid ? formatPhoneForDisplay(phone) : '-';
|
||
const hideContactItemClass = contactConsent ? '' : 'is-hidden';
|
||
const hideContactActionClass = (contactConsent && isPhoneValid) ? '' : 'is-hidden';
|
||
const initial = name ? name.charAt(0).toUpperCase() : '';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = `staff-card reveal reveal-delay-${index + 1}`;
|
||
|
||
card.innerHTML = `
|
||
<div class="staff-card-header">
|
||
<div class="staff-avatar">
|
||
<span class="staff-initial">${initial}</span>
|
||
<img src="${image}" alt="${name}" onerror="this.style.display='none'"/>
|
||
</div>
|
||
<div>
|
||
<div class="staff-name">${name}</div>
|
||
<div class="staff-role">${roleText}</div>
|
||
</div>
|
||
</div>
|
||
<div class="staff-card-body">
|
||
<div class="staff-info-grid">
|
||
<div class="staff-info-item"><span class="icon">📍</span><div><span class="label">${t('grp_c4a_label_location', 'Lokasi')}</span><span class="val">${locationText}</span></div></div>
|
||
<div class="staff-info-item">
|
||
<span class="icon">🕒</span>
|
||
<div style="flex:1">
|
||
<span class="label">${t('grp_c4a_label_service_hours', 'Jam Layanan')}</span>
|
||
<div class="schedule-list">${scheduleHTML}</div>
|
||
</div>
|
||
</div>
|
||
<div class="staff-info-item staff-contact-item ${hideContactItemClass}"><span class="icon">📞</span><div><span class="label">${t('grp_c4a_label_wa_contact', 'Kontak WhatsApp')}</span><span class="val">${displayPhone}</span></div></div>
|
||
</div>
|
||
<div class="staff-divider"></div>
|
||
<div class="staff-tasks-title">${t('grp_c4a_label_tasks', 'Tugas & Tanggung Jawab')}</div>
|
||
<ul class="staff-tasks">${tasksHTML}</ul>
|
||
</div>
|
||
<div class="staff-card-actions staff-contact-actions ${hideContactActionClass}">
|
||
<button class="btn-staff btn-staff-wa" data-wa="${encodedPhone}" aria-label="${t('grp_c4a_label_send_wa', 'Kirim Pesan WA')} ${name}"><svg class="wa-icon" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg> ${t('grp_c4a_label_send_wa', 'Kirim Pesan WA')}</button>
|
||
</div>
|
||
|
||
`;
|
||
|
||
container.appendChild(card);
|
||
|
||
// Setup WA button click handler dengan dekrips nomor
|
||
const waBtn = card.querySelector('[data-wa]');
|
||
if (waBtn) {
|
||
waBtn.addEventListener('click', () => {
|
||
const phoneNum = decodePhone(waBtn.dataset.wa);
|
||
if (phoneNum) {
|
||
window.open(`https://wa.me/${phoneNum}`, '_blank', 'noopener,noreferrer');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
// ============================================================
|
||
// --- Render SOP sebagai Card Grid dengan Search & Filter ---
|
||
// ============================================================
|
||
|
||
const CATEGORY_COLORS = {
|
||
akademik: 'sop-cat-akademik',
|
||
praktikum: 'sop-cat-praktikum',
|
||
};
|
||
|
||
/**
|
||
* Ambil label kategori
|
||
* @param {string} category - Kategori SOP ('akademik' atau 'praktikum')
|
||
* @returns {string} Label kategori
|
||
*/
|
||
function getCatLabel(category) {
|
||
const map = {
|
||
akademik: t('grp_c4a_sop_chip_akademik', 'Akademik'),
|
||
praktikum: t('grp_c4a_sop_chip_praktikum', 'Praktikum')
|
||
};
|
||
return map[category] || t('grp_c4a_sop_chip_akademik', 'Akademik');
|
||
}
|
||
|
||
/**
|
||
* Buat HTML untuk satu SOP card di grid.
|
||
* @param {Object} sop - Data objek SOP
|
||
* @returns {string} String markup HTML kartu SOP
|
||
*/
|
||
function buildSopCard(sop) {
|
||
const {
|
||
tab_id: tabId,
|
||
category,
|
||
icon = 'fa-file-alt',
|
||
title,
|
||
description,
|
||
procedures
|
||
} = sop;
|
||
|
||
const titleText = loc(title);
|
||
const descText = loc(description);
|
||
|
||
const catLabel = getCatLabel(category);
|
||
const catClass = CATEGORY_COLORS[category] || '';
|
||
|
||
return `
|
||
<article class="sop-grid-card reveal" data-tab-id="${tabId}" data-category="${category}" data-title="${titleText.toLowerCase()}">
|
||
<div class="sgc-header">
|
||
<div class="sgc-icon-wrap">
|
||
<i class="fas ${icon}"></i>
|
||
</div>
|
||
<div class="sgc-meta">
|
||
<h3 class="sgc-title">${titleText}</h3>
|
||
</div>
|
||
</div>
|
||
<div class="sgc-category-row">
|
||
<span class="sgc-category ${catClass}">${catLabel}</span>
|
||
</div>
|
||
<p class="sgc-desc">${descText}</p>
|
||
<div class="sgc-footer">
|
||
<div class="sgc-steps">
|
||
<i class="fas fa-list-ol"></i>
|
||
<span>${procedures.length} ${t('grp_c4a_label_steps', 'Langkah')}</span>
|
||
</div>
|
||
<button class="sgc-detail-btn" data-tab-id="${tabId}" aria-label="${t('grp_c4a_label_view_detail', 'Lihat Detail')} ${titleText}">
|
||
${t('grp_c4a_label_view_detail', 'Lihat Detail')} <i class="fas fa-arrow-right"></i>
|
||
</button>
|
||
</div>
|
||
</article>`;
|
||
}
|
||
|
||
/**
|
||
* Buat HTML untuk isi modal detail SOP.
|
||
* @param {Object} sop - Data objek SOP
|
||
* @returns {string} String markup HTML isi modal
|
||
*/
|
||
function buildSopModalContent(sop) {
|
||
const {
|
||
category,
|
||
icon = 'fa-file-alt',
|
||
title,
|
||
description,
|
||
purpose,
|
||
requirements,
|
||
procedures,
|
||
staff_responsible: staffResponsible,
|
||
additional_info: additionalInfo
|
||
} = sop;
|
||
|
||
const titleText = loc(title);
|
||
const descText = loc(description);
|
||
const purposeText = loc(purpose);
|
||
const additionalInfoText = loc(additionalInfo);
|
||
|
||
const catLabel = getCatLabel(category);
|
||
const catClass = CATEGORY_COLORS[category] || '';
|
||
|
||
const reqsHTML = requirements.map(req => `<li>${loc(req)}</li>`).join('');
|
||
const procsHTML = procedures.map(proc => `<li>${loc(proc)}</li>`).join('');
|
||
|
||
// staffResponsible bersifat opsional — sembunyikan blok jika tidak ada
|
||
const hasStaff = Array.isArray(staffResponsible) && staffResponsible.length > 0;
|
||
const staffHTML = hasStaff
|
||
? staffResponsible.map(staffName => `<span class="sop-staff-pill">${staffName}</span>`).join('')
|
||
: '';
|
||
const staffBlockHTML = hasStaff
|
||
? `<div class="sop-staff-block">
|
||
<div class="sop-block-label">${t('grp_c4a_label_responsible_staff', 'Staf Bertanggung Jawab (Bersifat Opsional)')}</div>
|
||
<div class="sop-staff-pills">${staffHTML}</div>
|
||
</div>`
|
||
: '';
|
||
|
||
// additionalInfo bersifat opsional — sembunyikan blok jika tidak ada
|
||
const additionalInfoHTML = additionalInfoText
|
||
? `<div class="sop-info-box">
|
||
<span class="iico">ℹ️</span>
|
||
<p class="sop-info-box-text"><strong>${t('grp_c4a_label_additional_info', 'Informasi Tambahan (Bersifat Opsional):')}</strong> ${additionalInfoText}</p>
|
||
</div>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="sop-modal-header">
|
||
<div class="sop-modal-header-top">
|
||
<div class="sop-modal-icon-wrap">
|
||
<i class="fas ${icon}"></i>
|
||
</div>
|
||
<div>
|
||
<div class="sop-modal-badges">
|
||
<span class="sgc-category ${catClass}">${catLabel}</span>
|
||
</div>
|
||
<h2 class="sop-modal-title" id="sop-modal-title">${titleText}</h2>
|
||
<p class="sop-modal-desc">${descText}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sop-modal-body">
|
||
<div class="sop-block">
|
||
<div class="sop-block-label">${t('grp_c4a_label_purpose', 'Tujuan')}</div>
|
||
<p class="sop-block-text">${purposeText}</p>
|
||
</div>
|
||
<div class="sop-two-col">
|
||
<div>
|
||
<div class="sop-block-label">${t('grp_c4a_label_requirements', 'Persyaratan')}</div>
|
||
<ul class="sop-list bullets">${reqsHTML}</ul>
|
||
</div>
|
||
<div>
|
||
<div class="sop-block-label">${t('grp_c4a_label_procedure', 'Alur Prosedur')}</div>
|
||
<ul class="sop-list numbered">${procsHTML}</ul>
|
||
</div>
|
||
</div>
|
||
${staffBlockHTML}
|
||
${additionalInfoHTML}
|
||
</div>`;
|
||
}
|
||
|
||
/**
|
||
* Render semua SOP card ke grid.
|
||
* @param {Array<Object>} sops - Array objek SOP
|
||
*/
|
||
export function renderSopCards(sops) {
|
||
const container = document.getElementById('sop-cards-container');
|
||
if (!container) return;
|
||
|
||
// simpan data global di element agar bisa diakses filter/search
|
||
container._sopData = sops;
|
||
|
||
// Tampilkan pesan jika data SOP kosong atau gagal dimuat
|
||
if (!sops || sops.length === 0) {
|
||
container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_sop', 'Data SOP tidak tersedia saat ini.')}</p>`;
|
||
updateResultInfo(0, 0);
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = sops.map(buildSopCard).join('');
|
||
updateResultInfo(sops.length, sops.length);
|
||
}
|
||
|
||
/** Update teks info jumlah hasil */
|
||
function updateResultInfo(shown, total) {
|
||
const el = document.getElementById('sop-result-info');
|
||
if (!el) return;
|
||
if (shown === total) {
|
||
const allTemplate = t('grp_c4a_sop_showing_all', 'Menampilkan {total} SOP');
|
||
el.textContent = allTemplate.replace('{total}', total);
|
||
} else {
|
||
const filteredTemplate = t('grp_c4a_sop_showing_filtered', 'Menampilkan {shown} dari {total} SOP');
|
||
el.textContent = filteredTemplate.replace('{shown}', shown).replace('{total}', total);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Memfilter dan menyaring kartu SOP berdasarkan query pencarian dan chip filter aktif.
|
||
*/
|
||
export function applySopFilter() {
|
||
const container = document.getElementById('sop-cards-container');
|
||
if (!container) return;
|
||
|
||
const query = (document.getElementById('sop-search-input')?.value || '').toLowerCase().trim();
|
||
const activeChip = document.querySelector('.sop-chip.active');
|
||
const activeFilter = activeChip ? activeChip.dataset.filter : 'semua';
|
||
|
||
const cards = container.querySelectorAll('.sop-grid-card');
|
||
let visibleCount = 0;
|
||
|
||
cards.forEach(card => {
|
||
const title = card.dataset.title || '';
|
||
const code = card.dataset.code || '';
|
||
const category = card.dataset.category || '';
|
||
|
||
const matchesSearch = !query || title.includes(query) || code.includes(query);
|
||
const matchesFilter = activeFilter === 'semua' || category === activeFilter;
|
||
|
||
const isVisible = matchesSearch && matchesFilter;
|
||
card.style.display = isVisible ? '' : 'none';
|
||
if (isVisible) visibleCount++;
|
||
});
|
||
|
||
const emptyState = document.getElementById('sop-empty-state');
|
||
if (emptyState) emptyState.style.display = visibleCount === 0 ? 'flex' : 'none';
|
||
|
||
updateResultInfo(visibleCount, cards.length);
|
||
}
|
||
|
||
|
||
/**
|
||
* Membuka modal detail untuk SOP tertentu berdasarkan tab_id.
|
||
* @param {string} tabId - ID unik tab/SOP yang ingin ditampilkan
|
||
*/
|
||
function openSopModal(tabId) {
|
||
const container = document.getElementById('sop-cards-container');
|
||
if (!container || !container._sopData) return;
|
||
const sop = container._sopData.find(item => item.tab_id === tabId);
|
||
if (!sop) return;
|
||
|
||
const modalContent = document.getElementById('sop-modal-content');
|
||
const modalOverlay = document.getElementById('sop-modal-overlay');
|
||
if (!modalContent || !modalOverlay) return;
|
||
|
||
modalContent.innerHTML = buildSopModalContent(sop);
|
||
modalOverlay.classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Gulir isi modal ke paling atas
|
||
const modalBox = document.getElementById('sop-modal-box');
|
||
if (modalBox) modalBox.scrollTop = 0;
|
||
}
|
||
|
||
/**
|
||
* Menutup modal detail SOP yang sedang aktif.
|
||
*/
|
||
function closeSopModal() {
|
||
const modalOverlay = document.getElementById('sop-modal-overlay');
|
||
if (modalOverlay) modalOverlay.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
/**
|
||
* Menginisialisasi interaksi halaman SOP (pencarian, filter chip, modal detail).
|
||
*/
|
||
export function initSopInteraction() {
|
||
// Search input
|
||
const searchInput = document.getElementById('sop-search-input');
|
||
const searchClear = document.getElementById('sop-search-clear');
|
||
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', () => {
|
||
if (searchClear) {
|
||
searchClear.style.display = searchInput.value ? 'flex' : 'none';
|
||
}
|
||
applySopFilter();
|
||
});
|
||
}
|
||
|
||
if (searchClear) {
|
||
searchClear.addEventListener('click', () => {
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
}
|
||
searchClear.style.display = 'none';
|
||
applySopFilter();
|
||
});
|
||
}
|
||
|
||
// Filter chips
|
||
document.querySelectorAll('.sop-chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('.sop-chip').forEach(activeChip => {
|
||
activeChip.classList.remove('active');
|
||
});
|
||
chip.classList.add('active');
|
||
applySopFilter();
|
||
});
|
||
});
|
||
|
||
// Empty state reset button
|
||
const emptyReset = document.getElementById('sop-empty-reset');
|
||
if (emptyReset) {
|
||
emptyReset.addEventListener('click', () => {
|
||
if (searchInput) {
|
||
searchInput.value = '';
|
||
}
|
||
if (searchClear) {
|
||
searchClear.style.display = 'none';
|
||
}
|
||
document.querySelectorAll('.sop-chip').forEach(activeChip => {
|
||
activeChip.classList.remove('active');
|
||
});
|
||
const allChip = document.querySelector('[data-filter="semua"]');
|
||
if (allChip) {
|
||
allChip.classList.add('active');
|
||
}
|
||
applySopFilter();
|
||
});
|
||
}
|
||
|
||
// Card detail button (event delegation)
|
||
const container = document.getElementById('sop-cards-container');
|
||
if (container) {
|
||
container.addEventListener('click', (event) => {
|
||
const btn = event.target.closest('.sgc-detail-btn');
|
||
if (btn) {
|
||
openSopModal(btn.dataset.tabId);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Modal close button
|
||
const closeBtn = document.getElementById('sop-modal-close');
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', closeSopModal);
|
||
}
|
||
|
||
// Close on overlay click
|
||
const overlay = document.getElementById('sop-modal-overlay');
|
||
if (overlay) {
|
||
overlay.addEventListener('click', (event) => {
|
||
if (event.target === overlay) {
|
||
closeSopModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Close on Escape key
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Escape') {
|
||
closeSopModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Kompatibilitas Mundur ---
|
||
|
||
/**
|
||
* Stub kompatibilitas mundur untuk renderSopTabs.
|
||
* @param {Array<Object>} sops - Array objek SOP
|
||
*/
|
||
export function renderSopTabs(sops) {
|
||
renderSopCards(sops);
|
||
}
|
||
|
||
/**
|
||
* Stub kompatibilitas mundur untuk initSopTabsInteraction.
|
||
*/
|
||
export function initSopTabsInteraction() {
|
||
initSopInteraction();
|
||
}
|
||
|
||
// --- Interaksi & Animasi ---
|
||
|
||
/**
|
||
* Menginisialisasi animasi scroll reveal dan counter statistik pada hero section.
|
||
*/
|
||
export function initAnimations() {
|
||
// Scroll Reveal
|
||
const revealObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (!entry.isIntersecting) return;
|
||
entry.target.classList.add('visible');
|
||
revealObserver.unobserve(entry.target);
|
||
});
|
||
}, { threshold: 0.1 });
|
||
|
||
document.querySelectorAll('.reveal').forEach((el) => revealObserver.observe(el));
|
||
|
||
// Hero Counter
|
||
const COUNT_ANIMATION_DURATION_MS = 800;
|
||
|
||
/**
|
||
* Menjalankan animasi hitung angka (counter)
|
||
* @param {Element} targetElement - Elemen target angka
|
||
* @param {number} targetValue - Nilai akhir counter
|
||
*/
|
||
function animateCount(targetElement, targetValue) {
|
||
if (!targetElement) return;
|
||
const stepValue = targetValue / (COUNT_ANIMATION_DURATION_MS / 16);
|
||
let currentValue = 0;
|
||
const timerId = setInterval(() => {
|
||
currentValue += stepValue;
|
||
if (currentValue >= targetValue) {
|
||
currentValue = targetValue;
|
||
clearInterval(timerId);
|
||
}
|
||
targetElement.textContent = Math.floor(currentValue).toString();
|
||
}, 16);
|
||
}
|
||
|
||
const staffCounter = document.getElementById('stat-staf');
|
||
const sopCounter = document.getElementById('stat-sop');
|
||
|
||
// Hitung jumlah data yang berhasil di-render (tidak termasuk template)
|
||
const totalStaffCards = document.querySelectorAll('.staff-card').length;
|
||
const totalSopCards = document.querySelectorAll('.sop-grid-card').length;
|
||
|
||
animateCount(staffCounter, totalStaffCards);
|
||
animateCount(sopCounter, totalSopCards);
|
||
}
|
||
|
||
// --- FAQ Accordion ---
|
||
|
||
/**
|
||
* Membuka item FAQ yang diklik; menutup item lain yang sebelumnya
|
||
* terbuka (perilaku accordion satu-terbuka-sekaligus).
|
||
* @param {Element} questionEl - Elemen .faq-question yang diklik
|
||
*/
|
||
function toggleFaq(questionEl) {
|
||
const item = questionEl.closest('.faq-item');
|
||
if (!item) return;
|
||
const isOpen = item.classList.contains('open');
|
||
|
||
// Tutup semua item terlebih dahulu
|
||
document.querySelectorAll('.faq-item').forEach(faqItem => {
|
||
faqItem.classList.remove('open');
|
||
});
|
||
|
||
// Buka item yang diklik hanya jika sebelumnya tertutup
|
||
if (!isOpen) {
|
||
item.classList.add('open');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Menginisialisasi event listener klik untuk setiap accordion FAQ.
|
||
*/
|
||
export function initFaq() {
|
||
document.querySelectorAll('.faq-question').forEach(questionEl => {
|
||
questionEl.addEventListener('click', () => toggleFaq(questionEl));
|
||
});
|
||
}
|
||
|
||
// --- Render FAQ ---
|
||
|
||
/**
|
||
* Merender section FAQ secara dinamis dari data-faq.json.
|
||
* Diinjeksikan ke dalam #faq-container di index.html.
|
||
* @param {Array<Object>} faqs - Array objek FAQ dari data-faq.json
|
||
*/
|
||
export function renderFaqSection(faqs) {
|
||
const container = document.getElementById('faq-container');
|
||
if (!container) return;
|
||
|
||
if (!faqs || faqs.length === 0) {
|
||
container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_faq', 'Data FAQ tidak tersedia saat ini.')}</p>`;
|
||
return;
|
||
}
|
||
|
||
|
||
const delayClasses = ['reveal-delay-1', 'reveal-delay-2', 'reveal-delay-3'];
|
||
|
||
const itemsHTML = faqs.map((faq, index) => {
|
||
const delayClass = delayClasses[index] || '';
|
||
return `
|
||
<div class="faq-item reveal ${delayClass}">
|
||
<div class="faq-question">
|
||
<span class="faq-question-text">${loc(faq.question)}</span>
|
||
<div class="faq-icon">+</div>
|
||
</div>
|
||
<div class="faq-answer">
|
||
<div class="faq-answer-inner">${loc(faq.answer)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = itemsHTML;
|
||
} |