Files
roda-student-web-if-develop…/groups/C4A/js/ui.js
T

669 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- 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;
}
/**
* Helper untuk melokalisasi teks dari objek bilingual {id, en} atau string biasa.
* @param {string|Object} field - Data teks (bisa string biasa atau objek {id, en})
* @returns {string} Teks terlokalisasi
*/
function loc(field) {
if (!field) return '';
if (typeof field === 'object') {
const lang = (window.getLang && typeof window.getLang === 'function') ? window.getLang() : 'id';
return field[lang] || field['id'] || '';
}
return String(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 scheduleHTML = schedule.map(sched =>
`<div class="schedule-item">` +
`<span class="s-day">${loc(sched.day)}</span>` +
`<span class="s-time">${loc(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">${loc(name)}</div>
<div class="staff-role">${loc(role)}</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">${loc(location)}</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 catLabel = getCatLabel(category);
const catClass = CATEGORY_COLORS[category] || '';
const titleText = loc(title);
const descText = loc(description);
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 catLabel = getCatLabel(category);
const catClass = CATEGORY_COLORS[category] || '';
const titleText = loc(title);
const descText = loc(description);
const purposeText = loc(purpose);
const additionalInfoText = loc(additionalInfo);
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">${loc(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;
const emptyState = document.getElementById('sop-empty-state');
// 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);
if (emptyState) emptyState.style.display = 'none';
return;
}
container.innerHTML = sops.map(buildSopCard).join('');
updateResultInfo(sops.length, sops.length);
if (emptyState) emptyState.style.display = 'none';
}
/** 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) {
// Tampilkan "SOP tidak ditemukan" HANYA jika memang ada data SOP tetapi hasil filter 0.
// Jika data SOP memang kosong (tidak ada card sama sekali di data source), sembunyikan.
const hasData = container._sopData && container._sopData.length > 0;
emptyState.style.display = (hasData && 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;
}