merge(c4a): combine bilingual and UI fix commits into feature branch

This commit is contained in:
cygouw
2026-06-05 14:56:58 +07:00
6 changed files with 380 additions and 63 deletions
+80
View File
@@ -569,6 +569,46 @@ const translations = {
kontrib_guideline_3: "Sertakan fakta dan data yang akurat dan dapat diverifikasi.", kontrib_guideline_3: "Sertakan fakta dan data yang akurat dan dapat diverifikasi.",
kontrib_guideline_4: "Hindari plagiarisme. Karya harus orisinal.", kontrib_guideline_4: "Hindari plagiarisme. Karya harus orisinal.",
kontrib_guideline_5: "Tim redaksi berhak menyunting konten untuk keperluan editorial.", kontrib_guideline_5: "Tim redaksi berhak menyunting konten untuk keperluan editorial.",
// --- Group C4A: Staf Pendukung & SOP ---
grp_c4a_hero_tag: "LAYANAN AKADEMIK \u2022 INFORMATIKA UNTAN",
grp_c4a_hero_title: "Staf Pendukung &amp; <br/><span class=\"highlight-orange\">Standar Operasional Prosedur (SOP)</span>",
grp_c4a_hero_desc: "Halaman ini menampilkan informasi petugas layanan akademik serta prosedur administrasi mahasiswa Program Studi Informatika UNTAN.",
grp_c4a_stat_staf: "Staf Aktif",
grp_c4a_stat_sop: "Jenis SOP",
grp_c4a_staff_tag: "Tim Kami",
grp_c4a_staff_title: "Staf Pendukung",
grp_c4a_staff_subtitle: "Staf yang siap melayani kebutuhan akademik dan operasional laboratorium di Prodi Informatika UNTAN.",
grp_c4a_sop_tag: "Prosedur Resmi",
grp_c4a_sop_title: "Standar Operasional Prosedur (SOP)",
grp_c4a_sop_subtitle: "Standar Operasional Prosedur untuk berbagai kebutuhan administrasi akademik mahasiswa. Gunakan pencarian atau filter untuk menemukan SOP yang Anda butuhkan.",
grp_c4a_sop_search_placeholder: "Cari SOP berdasarkan judul...",
grp_c4a_sop_chip_semua: "Semua",
grp_c4a_sop_chip_akademik: "Akademik",
grp_c4a_sop_chip_praktikum: "Praktikum",
grp_c4a_sop_empty_title: "SOP tidak ditemukan",
grp_c4a_sop_empty_desc: "Coba kata kunci lain atau reset filter pencarian.",
grp_c4a_sop_reset_btn: "Reset Pencarian",
grp_c4a_faq_tag: "Pertanyaan Umum",
grp_c4a_faq_title: "Pertanyaan yang Sering Diajukan",
grp_c4a_faq_subtitle: "Temukan jawaban cepat atas pertanyaan umum mahasiswa seputar layanan akademik prodi.",
grp_c4a_empty_staff: "Data staf pendukung tidak tersedia saat ini.",
grp_c4a_empty_sop: "Data SOP tidak tersedia saat ini.",
grp_c4a_empty_faq: "Data FAQ tidak tersedia saat ini.",
grp_c4a_label_location: "Lokasi",
grp_c4a_label_service_hours: "Jam Layanan",
grp_c4a_label_wa_contact: "Kontak WhatsApp",
grp_c4a_label_tasks: "Tugas & Tanggung Jawab",
grp_c4a_label_send_wa: "Kirim Pesan WA",
grp_c4a_label_steps: "Langkah",
grp_c4a_label_view_detail: "Lihat Detail",
grp_c4a_label_responsible_staff: "Staf Bertanggung Jawab (Bersifat Opsional)",
grp_c4a_label_additional_info: "Informasi Tambahan (Bersifat Opsional):",
grp_c4a_label_purpose: "Tujuan",
grp_c4a_label_requirements: "Persyaratan",
grp_c4a_label_procedure: "Alur Prosedur",
grp_c4a_sop_showing_all: "Menampilkan {total} SOP",
grp_c4a_sop_showing_filtered: "Menampilkan {shown} dari {total} SOP",
}, },
en: { en: {
@@ -1135,6 +1175,46 @@ const translations = {
kontrib_guideline_3: "Include accurate and verifiable facts and data.", kontrib_guideline_3: "Include accurate and verifiable facts and data.",
kontrib_guideline_4: "Avoid plagiarism. Work must be original.", kontrib_guideline_4: "Avoid plagiarism. Work must be original.",
kontrib_guideline_5: "The editorial team reserves the right to edit content for editorial purposes.", kontrib_guideline_5: "The editorial team reserves the right to edit content for editorial purposes.",
// --- Group C4A: Supporting Staff & SOP ---
grp_c4a_hero_tag: "ACADEMIC SERVICES \u2022 INFORMATICS UNTAN",
grp_c4a_hero_title: "Supporting Staff &amp; <br/><span class=\"highlight-orange\">Standard Operating Procedures (SOP)</span>",
grp_c4a_hero_desc: "This page presents information about academic service officers and administrative procedures for students of the Informatics Study Program at UNTAN.",
grp_c4a_stat_staf: "Active Staff",
grp_c4a_stat_sop: "SOP Types",
grp_c4a_staff_tag: "Our Team",
grp_c4a_staff_title: "Supporting Staff",
grp_c4a_staff_subtitle: "Staff ready to serve academic and laboratory operational needs at the Informatics Study Program, UNTAN.",
grp_c4a_sop_tag: "Official Procedures",
grp_c4a_sop_title: "Standard Operating Procedures (SOP)",
grp_c4a_sop_subtitle: "Standard Operating Procedures for various student academic administration needs. Use search or filter to find the SOP you need.",
grp_c4a_sop_search_placeholder: "Search SOP by title...",
grp_c4a_sop_chip_semua: "All",
grp_c4a_sop_chip_akademik: "Academic",
grp_c4a_sop_chip_praktikum: "Practicum",
grp_c4a_sop_empty_title: "No SOP found",
grp_c4a_sop_empty_desc: "Try another keyword or reset the search filter.",
grp_c4a_sop_reset_btn: "Reset Search",
grp_c4a_faq_tag: "Frequently Asked",
grp_c4a_faq_title: "Frequently Asked Questions",
grp_c4a_faq_subtitle: "Find quick answers to common student questions about the study program's academic services.",
grp_c4a_empty_staff: "Staff data is currently unavailable.",
grp_c4a_empty_sop: "SOP data is currently unavailable.",
grp_c4a_empty_faq: "FAQ data is currently unavailable.",
grp_c4a_label_location: "Location",
grp_c4a_label_service_hours: "Service Hours",
grp_c4a_label_wa_contact: "WhatsApp Contact",
grp_c4a_label_tasks: "Tasks & Responsibilities",
grp_c4a_label_send_wa: "Send WA Message",
grp_c4a_label_steps: "Steps",
grp_c4a_label_view_detail: "View Details",
grp_c4a_label_responsible_staff: "Responsible Staff (Optional)",
grp_c4a_label_additional_info: "Additional Information (Optional):",
grp_c4a_label_purpose: "Purpose",
grp_c4a_label_requirements: "Requirements",
grp_c4a_label_procedure: "Procedure Flow",
grp_c4a_sop_showing_all: "Showing {total} SOP",
grp_c4a_sop_showing_filtered: "Showing {shown} of {total} SOP",
} }
}; };
+125
View File
@@ -0,0 +1,125 @@
# Panduan Pengelolaan Data Kelompok C4A
Dokumentasi ini dibuat untuk memudahkan pengembang atau pengguna berikutnya dalam mengelola data Staf Pendukung, SOP, dan FAQ pada halaman C4A.
---
## 📋 Aturan Umum Pengelolaan Data
1. **Lokasi Berkas Data**:
Seluruh data disimpan dalam format JSON di direktori `groups/C4A/data/`:
- Data Staf: `groups/C4A/data/data-staff.json`
- Data SOP: `groups/C4A/data/data-sop.json`
- Data FAQ: `groups/C4A/data/data-faq.json`
2. **Dukungan Bilingual (ID/EN)**:
Semua teks konten yang ditampilkan ke pengguna mendukung dua bahasa. Anda perlu menggunakan objek dengan properti `id` (Bahasa Indonesia) dan `en` (Bahasa Inggris) pada setiap field teks. Jika properti `en` tidak diisi atau data masih berupa *string* biasa, sistem akan otomatis menggunakan Bahasa Indonesia sebagai *fallback*.
3. **Ikon FontAwesome (v4)**:
Jika ingin mengubah atau menambahkan logo/ikon pada data SOP, harap gunakan daftar ikon dari **FontAwesome v4** melalui tautan berikut:
👉 **[FontAwesome v4 Icons](https://fontawesome.com/v4/icons/)** (Contoh penggunaan: `fa-dot-circle-o`, `fa-graduation-cap`, `fa-flask`).
4. **Field Opsional**:
Pada data SOP, properti **`staff_responsible`** (Staf Bertanggung Jawab) dan **`additional_info`** (Informasi Tambahan) bersifat **opsional**. Jika informasi tersebut tidak tersedia, kosongkan nilainya atau hapus propertinya dari JSON agar UI secara otomatis menyembunyikan bloknya.
---
## 🛠️ Template Penambahan Data
Silakan salin-tempel (copy-paste) format di bawah ini ke dalam array JSON yang sesuai di folder `data/` untuk menambahkan data baru.
### 1. Template Data Staf (`data-staff.json`)
```json
{
"id": 3,
"name": "Nama Staf Beserta Gelar",
"role": {
"id": "Peran / Jabatan Staf",
"en": "Staff Role / Title"
},
"image": "assets/images/nama-file-foto.png",
"location": {
"id": "Lokasi Ruang Kerja / Kantor Staf",
"en": "Staff Workspace / Office Location"
},
"contact_consent": true,
"phone": "628xxxxxxxxxx",
"schedule": [
{
"day": {
"id": "Hari Kerja (contoh: Senin Jumat)",
"en": "Work Days (e.g., Monday Friday)"
},
"time": "07.3016.00 WIB",
"break": {
"id": "Jam Istirahat (contoh: istirahat 12.0013.00 WIB)",
"en": "Break Time (e.g., break 12.0013.00 WIB)"
}
}
],
"tasks": [
{
"id": "Tugas dan tanggung jawab utama 1",
"en": "Main task and responsibility 1"
},
{
"id": "Tugas dan tanggung jawab utama 2",
"en": "Main task and responsibility 2"
}
]
}
```
*Catatan: Letakkan file gambar/foto staf ke dalam direktori `groups/C4A/assets/images/` dan masukkan nama berkas fotonya pada properti `image` di atas. Untuk nomor telepon (`phone`), Anda dapat menggunakan format `08xxxxxxxxxx` atau `628xxxxxxxxxx` karena skrip JavaScript akan melakukan normalisasi secara otomatis.*
### 2. Template Data SOP (`data-sop.json`)
```json
{
"tab_id": "id-unik-sop-tanpa-spasi",
"tab_number": 9,
"category": "akademik",
"icon": "fa-file-text",
"title": {
"id": "Judul Lengkap SOP",
"en": "Full SOP Title"
},
"description": {
"id": "Deskripsi singkat mengenai SOP ini.",
"en": "Brief description of this SOP."
},
"purpose": {
"id": "Tujuan dibuatnya standar operasional prosedur ini.",
"en": "The purpose of creating this standard operating procedure."
},
"requirements": [
{ "id": "Persyaratan administratif/akademik 1", "en": "Administrative/academic requirement 1" },
{ "id": "Persyaratan administratif/akademik 2", "en": "Administrative/academic requirement 2" }
],
"procedures": [
{ "id": "Langkah alur prosedur ke-1", "en": "Procedure flow step 1" },
{ "id": "Langkah alur prosedur ke-2", "en": "Procedure flow step 2" }
],
"staff_responsible": [
"👤 Staf Administrasi Informatika / Informatics Administrative Staff"
],
"additional_info": {
"id": "Informasi tambahan penting.",
"en": "Important additional information."
}
}
```
*Catatan: Properti `category` bernilai antara `"akademik"` atau `"praktikum"`. Properti `staff_responsible` dan `additional_info` bersifat opsional.*
### 3. Template Data FAQ (`data-faq.json`)
```json
{
"id": 11,
"question": {
"id": "Pertanyaan yang sering diajukan?",
"en": "Frequently asked question?"
},
"answer": {
"id": "Jawaban dari pertanyaan tersebut. Boleh menggunakan tag HTML seperti <strong>tebal</strong>.",
"en": "Answer to the question. HTML tags like <strong>bold</strong> can be used."
}
}
```
+23 -21
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id" class="scroll-smooth"> <html lang="id" class="scroll-smooth">
<head> <head>
@@ -1160,13 +1160,13 @@
<!-- HERO SECTION --> <!-- HERO SECTION -->
<section class="c4a-hero reveal"> <section class="c4a-hero reveal">
<div class="c4a-hero-inner"> <div class="c4a-hero-inner">
<div class="c4a-hero-tag">LAYANAN AKADEMIK &bull; INFORMATIKA UNTAN <div class="c4a-hero-tag" data-i18n="grp_c4a_hero_tag">LAYANAN AKADEMIK &bull; INFORMATIKA UNTAN
</div> </div>
<h1 class="c4a-hero-title"> <h1 class="c4a-hero-title" data-i18n-html="grp_c4a_hero_title">
Staf Pendukung &amp; <br /> Staf Pendukung &amp; <br/>
<span class="highlight-orange">Standar Operasional Prosedur (SOP)</span> <span class="highlight-orange">Standar Operasional Prosedur (SOP)</span>
</h1> </h1>
<p class="c4a-hero-desc">Halaman ini menampilkan informasi petugas <p class="c4a-hero-desc" data-i18n="grp_c4a_hero_desc">Halaman ini menampilkan informasi petugas
layanan akademik serta layanan akademik serta
prosedur administrasi prosedur administrasi
mahasiswa mahasiswa
@@ -1174,12 +1174,12 @@
<div class="hero-stats"> <div class="hero-stats">
<div class="stat-item"> <div class="stat-item">
<div class="stat-number" id="stat-staf">0</div> <div class="stat-number" id="stat-staf">0</div>
<div class="stat-label">Staf Aktif</div> <div class="stat-label" data-i18n="grp_c4a_stat_staf">Staf Aktif</div>
</div> </div>
<div class="stat-divider"></div> <div class="stat-divider"></div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-number" id="stat-sop">0</div> <div class="stat-number" id="stat-sop">0</div>
<div class="stat-label">Jenis SOP</div> <div class="stat-label" data-i18n="grp_c4a_stat_sop">Jenis SOP</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1188,10 +1188,10 @@
<!-- STAFF SECTION --> <!-- STAFF SECTION -->
<section class="content-section"> <section class="content-section">
<div class="section-header"> <div class="section-header">
<div class="section-tag"><span class="dot"></span> <span>Tim <div class="section-tag"><span class="dot"></span> <span data-i18n="grp_c4a_staff_tag">Tim
Kami</span></div> Kami</span></div>
<h2 class="section-title">Staf Pendukung</h2> <h2 class="section-title" data-i18n="grp_c4a_staff_title">Staf Pendukung</h2>
<p class="section-subtitle">Staf yang siap melayani kebutuhan <p class="section-subtitle" data-i18n="grp_c4a_staff_subtitle">Staf yang siap melayani kebutuhan
akademik dan operasional akademik dan operasional
laboratorium di Prodi Informatika UNTAN.</p> laboratorium di Prodi Informatika UNTAN.</p>
</div> </div>
@@ -1203,10 +1203,10 @@
<!-- SOP SECTION --> <!-- SOP SECTION -->
<section class="sop-section"> <section class="sop-section">
<div class="section-header"> <div class="section-header">
<div class="section-tag"><span class="dot"></span> <span>Prosedur <div class="section-tag"><span class="dot"></span> <span data-i18n="grp_c4a_sop_tag">Prosedur
Resmi</span></div> Resmi</span></div>
<h2 class="section-title">Standar Operasional Prosedur (SOP)</h2> <h2 class="section-title" data-i18n="grp_c4a_sop_title">Standar Operasional Prosedur (SOP)</h2>
<p class="section-subtitle">Standar Operasional Prosedur untuk <p class="section-subtitle" data-i18n="grp_c4a_sop_subtitle">Standar Operasional Prosedur untuk
berbagai kebutuhan administrasi berbagai kebutuhan administrasi
akademik mahasiswa. Gunakan pencarian atau filter untuk menemukan SOP yang Anda butuhkan. akademik mahasiswa. Gunakan pencarian atau filter untuk menemukan SOP yang Anda butuhkan.
</p> </p>
@@ -1217,6 +1217,7 @@
<div class="sop-search-wrap"> <div class="sop-search-wrap">
<i class="fas fa-search sop-search-icon"></i> <i class="fas fa-search sop-search-icon"></i>
<input type="search" id="sop-search-input" class="sop-search-input" <input type="search" id="sop-search-input" class="sop-search-input"
data-i18n-placeholder="grp_c4a_sop_search_placeholder"
placeholder="Cari SOP berdasarkan judul..." autocomplete="off" aria-label="Cari SOP" /> placeholder="Cari SOP berdasarkan judul..." autocomplete="off" aria-label="Cari SOP" />
<button class="sop-search-clear" id="sop-search-clear" aria-label="Hapus pencarian" <button class="sop-search-clear" id="sop-search-clear" aria-label="Hapus pencarian"
style="display:none;"> style="display:none;">
@@ -1225,12 +1226,12 @@
</div> </div>
<div class="sop-filter-chips" id="sop-filter-chips" role="group" <div class="sop-filter-chips" id="sop-filter-chips" role="group"
aria-label="Filter kategori SOP"> aria-label="Filter kategori SOP">
<button class="sop-chip active" data-filter="semua" id="chip-semua">Semua</button> <button class="sop-chip active" data-filter="semua" id="chip-semua" data-i18n="grp_c4a_sop_chip_semua">Semua</button>
<button class="sop-chip" data-filter="akademik" id="chip-akademik"> <button class="sop-chip" data-filter="akademik" id="chip-akademik">
<i class="fas fa-graduation-cap"></i> <span>Akademik</span> <i class="fas fa-graduation-cap"></i> <span data-i18n="grp_c4a_sop_chip_akademik">Akademik</span>
</button> </button>
<button class="sop-chip" data-filter="praktikum" id="chip-praktikum"> <button class="sop-chip" data-filter="praktikum" id="chip-praktikum">
<i class="fas fa-flask"></i> <span>Praktikum</span> <i class="fas fa-flask"></i> <span data-i18n="grp_c4a_sop_chip_praktikum">Praktikum</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1244,8 +1245,8 @@
<!-- EMPTY STATE --> <!-- EMPTY STATE -->
<div class="sop-empty-state" id="sop-empty-state" style="display:none;"> <div class="sop-empty-state" id="sop-empty-state" style="display:none;">
<i class="fas fa-search-minus sop-empty-icon"></i> <i class="fas fa-search-minus sop-empty-icon"></i>
<p class="sop-empty-title">SOP tidak ditemukan</p> <p class="sop-empty-title" data-i18n="grp_c4a_sop_empty_title">SOP tidak ditemukan</p>
<p class="sop-empty-desc">Coba kata kunci lain atau reset <p class="sop-empty-desc" data-i18n="grp_c4a_sop_empty_desc">Coba kata kunci lain atau reset
filter pencarian.</p> filter pencarian.</p>
<button class="sop-empty-reset" id="sop-empty-reset"> <button class="sop-empty-reset" id="sop-empty-reset">
<i class="fas fa-undo"></i> <span data-i18n="grp_c4a_sop_reset_btn">Reset Pencarian</span> <i class="fas fa-undo"></i> <span data-i18n="grp_c4a_sop_reset_btn">Reset Pencarian</span>
@@ -1268,12 +1269,13 @@
<section class="faq-section-wrap"> <section class="faq-section-wrap">
<div class="faq-section-inner"> <div class="faq-section-inner">
<div class="faq-header reveal"> <div class="faq-header reveal">
<div class="section-tag"><span class="dot"></span> <span>Pertanyaan Umum</span></div> <div class="section-tag"><span class="dot"></span> <span data-i18n="grp_c4a_faq_tag">Pertanyaan Umum</span></div>
<h2>Pertanyaan yang Sering Diajukan</h2> <h2 data-i18n="grp_c4a_faq_title">Pertanyaan yang Sering Diajukan</h2>
<p>Temukan jawaban cepat atas pertanyaan umum mahasiswa <p data-i18n="grp_c4a_faq_subtitle">Temukan jawaban cepat atas pertanyaan umum mahasiswa
seputar layanan akademik prodi.</p> seputar layanan akademik prodi.</p>
</div> </div>
<div class="faq-wrap" id="faq-container"> <div class="faq-wrap" id="faq-container">
<!-- Item FAQ diisi secara dinamis melalui main.js berdasarkan data-faq.json --> <!-- Item FAQ diisi secara dinamis melalui main.js berdasarkan data-faq.json -->
</div> </div>
+2
View File
@@ -52,3 +52,5 @@ export async function getFaqData() {
return []; return [];
} }
} }
+19 -4
View File
@@ -10,12 +10,14 @@ import {
renderFaqSection, renderFaqSection,
initSopInteraction, initSopInteraction,
initAnimations, initAnimations,
initFaq initFaq,
applySopFilter
} from './ui.js'; } from './ui.js';
// Cache data secara global untuk mendukung rendering UI // Cache data secara global untuk mendukung rendering UI
let cachedStaffData = []; let cachedStaffData = [];
let cachedSopData = []; let cachedSopData = [];
let cachedFaqData = [];
/** /**
* Menginisialisasi aplikasi dengan mengambil seluruh data secara pararel, * Menginisialisasi aplikasi dengan mengambil seluruh data secara pararel,
@@ -32,10 +34,11 @@ async function initApp() {
cachedStaffData = staffData || []; cachedStaffData = staffData || [];
cachedSopData = sopData || []; cachedSopData = sopData || [];
cachedFaqData = faqData || [];
renderStaffCards(cachedStaffData); renderStaffCards(cachedStaffData);
renderSopCards(cachedSopData); renderSopCards(cachedSopData);
renderFaqSection(faqData || []); renderFaqSection(cachedFaqData);
initSopInteraction(); initSopInteraction();
initFaq(); initFaq();
@@ -49,8 +52,20 @@ async function initApp() {
} }
} }
// NOTE: Fitur bilingual sudah dihapus - languageChanged listener tidak lagi diperlukan // Re-render UI saat bahasa berubah (data tetap sama, hanya label UI yang berganti via t())
// Silakan hapus atau archive jika ada implementasi global untuk bahasa document.addEventListener('languageChanged', () => {
renderStaffCards(cachedStaffData);
renderSopCards(cachedSopData);
renderFaqSection(cachedFaqData);
initFaq();
applySopFilter();
// Re-inisialisasi observer agar elemen .reveal yang baru di-render mendapat class .visible
setTimeout(() => {
initAnimations();
}, 100);
});
// Jalankan inisialisasi aplikasi saat DOM siap // Jalankan inisialisasi aplikasi saat DOM siap
document.addEventListener('DOMContentLoaded', initApp); document.addEventListener('DOMContentLoaded', initApp);
+131 -38
View File
@@ -1,5 +1,31 @@
// --- Helper Fungsi --- // --- 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... * Normalisasi nomor telepon ke format standard e.g. 628...
* @param {string} phone - Nomor telepon mentah * @param {string} phone - Nomor telepon mentah
@@ -36,6 +62,29 @@ function isValidWhatsAppNumber(phone) {
return norm.startsWith('628') && norm.length >= 10 && norm.length <= 15; 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. * Merender daftar staf pendukung ke dalam container HTML.
* @param {Array<Object>} staffs - Array data staf pendukung * @param {Array<Object>} staffs - Array data staf pendukung
@@ -47,7 +96,7 @@ export function renderStaffCards(staffs) {
container.innerHTML = ''; container.innerHTML = '';
if (!staffs || staffs.length === 0) { if (!staffs || staffs.length === 0) {
container.innerHTML = `<p class="empty-state-msg">Data staf pendukung tidak tersedia saat ini.</p>`; container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_staff', 'Data staf pendukung tidak tersedia saat ini.')}</p>`;
return; return;
} }
@@ -58,18 +107,24 @@ export function renderStaffCards(staffs) {
image, image,
location, location,
phone, phone,
contact_consent: contactConsent,
schedule, schedule,
tasks tasks
} = staff; } = staff;
const scheduleHTML = schedule.map(sched => const scheduleHTML = schedule.map(sched =>
`<div class="schedule-item">` + `<div class="schedule-item">` +
`<span class="s-day">${sched.day}</span>` + `<span class="s-day">${loc(sched.day)}</span>` +
`<span class="s-time">${sched.time} <span class="s-break">| ${sched.break}</span></span>` + `<span class="s-time">${loc(sched.time)} <span class="s-break">| ${loc(sched.break)}</span></span>` +
`</div>` `</div>`
).join(''); ).join('');
const tasksHTML = tasks.map(task => `<li>${task}</li>`).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 initial = name ? name.charAt(0).toUpperCase() : '';
const card = document.createElement('div'); const card = document.createElement('div');
@@ -82,29 +137,44 @@ export function renderStaffCards(staffs) {
<img src="${image}" alt="${name}" onerror="this.style.display='none'"/> <img src="${image}" alt="${name}" onerror="this.style.display='none'"/>
</div> </div>
<div> <div>
<div class="staff-name">${name}</div> <div class="staff-name">${loc(name)}</div>
<div class="staff-role">${role}</div> <div class="staff-role">${loc(role)}</div>
</div> </div>
</div> </div>
<div class="staff-card-body"> <div class="staff-card-body">
<div class="staff-info-grid"> <div class="staff-info-grid">
<div class="staff-info-item"><span class="icon">📍</span><div><span class="label">Lokasi</span><span class="val">${location}</span></div></div> <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"> <div class="staff-info-item">
<span class="icon">🕒</span> <span class="icon">🕒</span>
<div style="flex:1"> <div style="flex:1">
<span class="label">Jam Layanan</span> <span class="label">${t('grp_c4a_label_service_hours', 'Jam Layanan')}</span>
<div class="schedule-list">${scheduleHTML}</div> <div class="schedule-list">${scheduleHTML}</div>
</div> </div>
</div> </div>
<div class="staff-info-item"><span class="icon">📞</span><div><span class="label">Kontak</span><span class="val">${phone}</span></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>
<div class="staff-divider"></div> <div class="staff-divider"></div>
<div class="staff-tasks-title">Tugas & Tanggung Jawab</div> <div class="staff-tasks-title">${t('grp_c4a_label_tasks', 'Tugas & Tanggung Jawab')}</div>
<ul class="staff-tasks">${tasksHTML}</ul> <ul class="staff-tasks">${tasksHTML}</ul>
</div> </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); 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');
}
});
}
}); });
} }
// ============================================================ // ============================================================
@@ -122,8 +192,11 @@ const CATEGORY_COLORS = {
* @returns {string} Label kategori * @returns {string} Label kategori
*/ */
function getCatLabel(category) { function getCatLabel(category) {
const map = { akademik: 'Akademik', praktikum: 'Praktikum' }; const map = {
return map[category] || 'Akademik'; 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');
} }
/** /**
@@ -143,28 +216,30 @@ function buildSopCard(sop) {
const catLabel = getCatLabel(category); const catLabel = getCatLabel(category);
const catClass = CATEGORY_COLORS[category] || ''; const catClass = CATEGORY_COLORS[category] || '';
const titleText = loc(title);
const descText = loc(description);
return ` return `
<article class="sop-grid-card reveal" data-tab-id="${tabId}" data-category="${category}" data-title="${title.toLowerCase()}"> <article class="sop-grid-card reveal" data-tab-id="${tabId}" data-category="${category}" data-title="${titleText.toLowerCase()}">
<div class="sgc-header"> <div class="sgc-header">
<div class="sgc-icon-wrap"> <div class="sgc-icon-wrap">
<i class="fas ${icon}"></i> <i class="fas ${icon}"></i>
</div> </div>
<div class="sgc-meta"> <div class="sgc-meta">
<h3 class="sgc-title">${title}</h3> <h3 class="sgc-title">${titleText}</h3>
</div> </div>
</div> </div>
<div class="sgc-category-row"> <div class="sgc-category-row">
<span class="sgc-category ${catClass}">${catLabel}</span> <span class="sgc-category ${catClass}">${catLabel}</span>
</div> </div>
<p class="sgc-desc">${description}</p> <p class="sgc-desc">${descText}</p>
<div class="sgc-footer"> <div class="sgc-footer">
<div class="sgc-steps"> <div class="sgc-steps">
<i class="fas fa-list-ol"></i> <i class="fas fa-list-ol"></i>
<span>${procedures.length} Langkah</span> <span>${procedures.length} ${t('grp_c4a_label_steps', 'Langkah')}</span>
</div> </div>
<button class="sgc-detail-btn" data-tab-id="${tabId}" aria-label="Lihat Detail ${title}"> <button class="sgc-detail-btn" data-tab-id="${tabId}" aria-label="${t('grp_c4a_label_view_detail', 'Lihat Detail')} ${titleText}">
Lihat Detail <i class="fas fa-arrow-right"></i> ${t('grp_c4a_label_view_detail', 'Lihat Detail')} <i class="fas fa-arrow-right"></i>
</button> </button>
</div> </div>
</article>`; </article>`;
@@ -191,26 +266,31 @@ function buildSopModalContent(sop) {
const catLabel = getCatLabel(category); const catLabel = getCatLabel(category);
const catClass = CATEGORY_COLORS[category] || ''; const catClass = CATEGORY_COLORS[category] || '';
const reqsHTML = requirements.map(req => `<li>${req}</li>`).join(''); const titleText = loc(title);
const procsHTML = procedures.map(proc => `<li>${proc}</li>`).join(''); 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 // staffResponsible bersifat opsional — sembunyikan blok jika tidak ada
const hasStaff = Array.isArray(staffResponsible) && staffResponsible.length > 0; const hasStaff = Array.isArray(staffResponsible) && staffResponsible.length > 0;
const staffHTML = hasStaff const staffHTML = hasStaff
? staffResponsible.map(staffName => `<span class="sop-staff-pill">${staffName}</span>`).join('') ? staffResponsible.map(staffName => `<span class="sop-staff-pill">${loc(staffName)}</span>`).join('')
: ''; : '';
const staffBlockHTML = hasStaff const staffBlockHTML = hasStaff
? `<div class="sop-staff-block"> ? `<div class="sop-staff-block">
<div class="sop-block-label">Staf Bertanggung Jawab (Bersifat Opsional)</div> <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 class="sop-staff-pills">${staffHTML}</div>
</div>` </div>`
: ''; : '';
// additionalInfo bersifat opsional — sembunyikan blok jika tidak ada // additionalInfo bersifat opsional — sembunyikan blok jika tidak ada
const additionalInfoHTML = additionalInfo const additionalInfoHTML = additionalInfoText
? `<div class="sop-info-box"> ? `<div class="sop-info-box">
<span class="iico">️</span> <span class="iico">️</span>
<p class="sop-info-box-text"><strong>Informasi Tambahan (Bersifat Opsional):</strong> ${additionalInfo}</p> <p class="sop-info-box-text"><strong>${t('grp_c4a_label_additional_info', 'Informasi Tambahan (Bersifat Opsional):')}</strong> ${additionalInfoText}</p>
</div>` </div>`
: ''; : '';
@@ -224,23 +304,23 @@ function buildSopModalContent(sop) {
<div class="sop-modal-badges"> <div class="sop-modal-badges">
<span class="sgc-category ${catClass}">${catLabel}</span> <span class="sgc-category ${catClass}">${catLabel}</span>
</div> </div>
<h2 class="sop-modal-title" id="sop-modal-title">${title}</h2> <h2 class="sop-modal-title" id="sop-modal-title">${titleText}</h2>
<p class="sop-modal-desc">${description}</p> <p class="sop-modal-desc">${descText}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="sop-modal-body"> <div class="sop-modal-body">
<div class="sop-block"> <div class="sop-block">
<div class="sop-block-label">Tujuan</div> <div class="sop-block-label">${t('grp_c4a_label_purpose', 'Tujuan')}</div>
<p class="sop-block-text">${purpose}</p> <p class="sop-block-text">${purposeText}</p>
</div> </div>
<div class="sop-two-col"> <div class="sop-two-col">
<div> <div>
<div class="sop-block-label">Persyaratan</div> <div class="sop-block-label">${t('grp_c4a_label_requirements', 'Persyaratan')}</div>
<ul class="sop-list bullets">${reqsHTML}</ul> <ul class="sop-list bullets">${reqsHTML}</ul>
</div> </div>
<div> <div>
<div class="sop-block-label">Alur Prosedur</div> <div class="sop-block-label">${t('grp_c4a_label_procedure', 'Alur Prosedur')}</div>
<ul class="sop-list numbered">${procsHTML}</ul> <ul class="sop-list numbered">${procsHTML}</ul>
</div> </div>
</div> </div>
@@ -260,15 +340,19 @@ export function renderSopCards(sops) {
// simpan data global di element agar bisa diakses filter/search // simpan data global di element agar bisa diakses filter/search
container._sopData = sops; container._sopData = sops;
const emptyState = document.getElementById('sop-empty-state');
// Tampilkan pesan jika data SOP kosong atau gagal dimuat // Tampilkan pesan jika data SOP kosong atau gagal dimuat
if (!sops || sops.length === 0) { if (!sops || sops.length === 0) {
container.innerHTML = `<p class="empty-state-msg">Data SOP tidak tersedia saat ini.</p>`; container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_sop', 'Data SOP tidak tersedia saat ini.')}</p>`;
updateResultInfo(0, 0); updateResultInfo(0, 0);
if (emptyState) emptyState.style.display = 'none';
return; return;
} }
container.innerHTML = sops.map(buildSopCard).join(''); container.innerHTML = sops.map(buildSopCard).join('');
updateResultInfo(sops.length, sops.length); updateResultInfo(sops.length, sops.length);
if (emptyState) emptyState.style.display = 'none';
} }
/** Update teks info jumlah hasil */ /** Update teks info jumlah hasil */
@@ -276,16 +360,18 @@ function updateResultInfo(shown, total) {
const el = document.getElementById('sop-result-info'); const el = document.getElementById('sop-result-info');
if (!el) return; if (!el) return;
if (shown === total) { if (shown === total) {
el.textContent = `Menampilkan ${total} SOP`; const allTemplate = t('grp_c4a_sop_showing_all', 'Menampilkan {total} SOP');
el.textContent = allTemplate.replace('{total}', total);
} else { } else {
el.textContent = `Menampilkan ${shown} dari ${total} SOP`; 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. * Memfilter dan menyaring kartu SOP berdasarkan query pencarian dan chip filter aktif.
*/ */
function applySopFilter() { export function applySopFilter() {
const container = document.getElementById('sop-cards-container'); const container = document.getElementById('sop-cards-container');
if (!container) return; if (!container) return;
@@ -310,11 +396,17 @@ function applySopFilter() {
}); });
const emptyState = document.getElementById('sop-empty-state'); const emptyState = document.getElementById('sop-empty-state');
if (emptyState) emptyState.style.display = visibleCount === 0 ? 'flex' : 'none'; 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); updateResultInfo(visibleCount, cards.length);
} }
/** /**
* Membuka modal detail untuk SOP tertentu berdasarkan tab_id. * Membuka modal detail untuk SOP tertentu berdasarkan tab_id.
* @param {string} tabId - ID unik tab/SOP yang ingin ditampilkan * @param {string} tabId - ID unik tab/SOP yang ingin ditampilkan
@@ -552,10 +644,11 @@ export function renderFaqSection(faqs) {
if (!container) return; if (!container) return;
if (!faqs || faqs.length === 0) { if (!faqs || faqs.length === 0) {
container.innerHTML = `<p class="empty-state-msg">Data FAQ tidak tersedia saat ini.</p>`; container.innerHTML = `<p class="empty-state-msg">${t('grp_c4a_empty_faq', 'Data FAQ tidak tersedia saat ini.')}</p>`;
return; return;
} }
const delayClasses = ['reveal-delay-1', 'reveal-delay-2', 'reveal-delay-3']; const delayClasses = ['reveal-delay-1', 'reveal-delay-2', 'reveal-delay-3'];
const itemsHTML = faqs.map((faq, index) => { const itemsHTML = faqs.map((faq, index) => {
@@ -563,11 +656,11 @@ export function renderFaqSection(faqs) {
return ` return `
<div class="faq-item reveal ${delayClass}"> <div class="faq-item reveal ${delayClass}">
<div class="faq-question"> <div class="faq-question">
<span class="faq-question-text">${faq.question}</span> <span class="faq-question-text">${loc(faq.question)}</span>
<div class="faq-icon">+</div> <div class="faq-icon">+</div>
</div> </div>
<div class="faq-answer"> <div class="faq-answer">
<div class="faq-answer-inner">${faq.answer}</div> <div class="faq-answer-inner">${loc(faq.answer)}</div>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');