378 lines
9.6 KiB
JavaScript
378 lines
9.6 KiB
JavaScript
/**
|
|
* Bank Sampah Digital - Main JavaScript File
|
|
* Optimized dengan modern patterns dan clean code principles
|
|
*/
|
|
|
|
class BpsRwApp {
|
|
constructor() {
|
|
this.config = {
|
|
animationDuration: 600,
|
|
toastDuration: 3000,
|
|
counterSpeed: 20,
|
|
observerThreshold: 0.1,
|
|
counterThreshold: 0.5
|
|
};
|
|
|
|
this.observers = new Map();
|
|
this.timers = new Set();
|
|
}
|
|
|
|
/**
|
|
* Smooth scroll untuk anchor links
|
|
*/
|
|
initSmoothScroll() {
|
|
const anchors = document.querySelectorAll('a[href^="#"]');
|
|
anchors.forEach(anchor => {
|
|
anchor.addEventListener('click', this.handleAnchorClick.bind(this));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle anchor click dengan error handling
|
|
*/
|
|
handleAnchorClick(event) {
|
|
event.preventDefault();
|
|
const href = event.currentTarget.getAttribute('href');
|
|
const target = document.querySelector(href);
|
|
|
|
if (target) {
|
|
target.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
} else {
|
|
console.warn(`Target element ${href} tidak ditemukan`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Intersection Observer untuk scroll animations
|
|
*/
|
|
initScrollAnimations() {
|
|
const options = {
|
|
threshold: this.config.observerThreshold,
|
|
rootMargin: '0px 0px -50px 0px'
|
|
};
|
|
|
|
const observer = new IntersectionObserver(
|
|
this.handleIntersection.bind(this),
|
|
options
|
|
);
|
|
|
|
this.observers.set('scrollAnimation', observer);
|
|
this.observeElements(['.card', '.stat'], observer);
|
|
}
|
|
|
|
/**
|
|
* Handle intersection untuk animasi
|
|
*/
|
|
handleIntersection(entries) {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
this.animateElement(entry.target);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animasi element dengan transition
|
|
*/
|
|
animateElement(element) {
|
|
element.style.opacity = '1';
|
|
element.style.transform = 'translateY(0)';
|
|
element.classList.add('animate-fade-in');
|
|
}
|
|
|
|
/**
|
|
* Setup initial styles untuk animasi
|
|
*/
|
|
setupAnimationStyles(element) {
|
|
Object.assign(element.style, {
|
|
opacity: '0',
|
|
transform: 'translateY(20px)',
|
|
transition: `opacity ${this.config.animationDuration}ms ease, transform ${this.config.animationDuration}ms ease`
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Observer multiple elements dengan selector
|
|
*/
|
|
observeElements(selectors, observer) {
|
|
selectors.forEach(selector => {
|
|
document.querySelectorAll(selector).forEach(element => {
|
|
this.setupAnimationStyles(element);
|
|
observer.observe(element);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Counter animation dengan performance optimization
|
|
*/
|
|
initCounterAnimation() {
|
|
const counters = document.querySelectorAll('.stat-value');
|
|
|
|
const observer = new IntersectionObserver(
|
|
this.handleCounterIntersection.bind(this),
|
|
{ threshold: this.config.counterThreshold }
|
|
);
|
|
|
|
this.observers.set('counter', observer);
|
|
counters.forEach(counter => observer.observe(counter));
|
|
}
|
|
|
|
/**
|
|
* Handle counter intersection
|
|
*/
|
|
handleCounterIntersection(entries) {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
this.animateCounter(entry.target);
|
|
this.observers.get('counter')?.unobserve(entry.target);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animasi counter dengan format preservation
|
|
*/
|
|
animateCounter(counter) {
|
|
const originalText = counter.textContent;
|
|
const target = this.extractNumber(originalText);
|
|
const isDecimal = originalText.includes('.');
|
|
const suffix = this.extractSuffix(originalText);
|
|
|
|
const increment = target / (this.config.animationDuration / this.config.counterSpeed);
|
|
let current = 0;
|
|
|
|
const timer = setInterval(() => {
|
|
current += increment;
|
|
|
|
if (current >= target) {
|
|
current = target;
|
|
clearInterval(timer);
|
|
this.timers.delete(timer);
|
|
}
|
|
|
|
counter.textContent = this.formatCounter(current, isDecimal, suffix);
|
|
}, this.config.counterSpeed);
|
|
|
|
this.timers.add(timer);
|
|
}
|
|
|
|
/**
|
|
* Extract number dari text
|
|
*/
|
|
extractNumber(text) {
|
|
const match = text.match(/[\d.,]+/);
|
|
return match ? parseFloat(match[0].replace(',', '')) : 0;
|
|
}
|
|
|
|
/**
|
|
* Extract suffix dari text
|
|
*/
|
|
extractSuffix(text) {
|
|
return text.replace(/[\d.,\s]+/, '').trim();
|
|
}
|
|
|
|
/**
|
|
* Format counter value
|
|
*/
|
|
formatCounter(value, isDecimal, suffix) {
|
|
const formatted = isDecimal
|
|
? value.toFixed(1)
|
|
: Math.floor(value).toLocaleString('id-ID');
|
|
|
|
return suffix ? `${formatted} ${suffix}` : formatted;
|
|
}
|
|
|
|
/**
|
|
* Theme management
|
|
*/
|
|
initThemeSwitch() {
|
|
const toggle = document.querySelector('#theme-toggle');
|
|
|
|
if (toggle) {
|
|
toggle.addEventListener('change', this.handleThemeChange.bind(this));
|
|
}
|
|
|
|
this.loadSavedTheme();
|
|
}
|
|
|
|
/**
|
|
* Handle theme change
|
|
*/
|
|
handleThemeChange(event) {
|
|
const theme = event.target.checked ? 'dark' : 'light';
|
|
this.setTheme(theme);
|
|
}
|
|
|
|
/**
|
|
* Set theme dan save ke localStorage
|
|
*/
|
|
setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('theme', theme);
|
|
}
|
|
|
|
/**
|
|
* Load saved theme
|
|
*/
|
|
loadSavedTheme() {
|
|
const savedTheme = localStorage.getItem('theme') || 'emerald';
|
|
this.setTheme(savedTheme);
|
|
}
|
|
|
|
/**
|
|
* Loading state management
|
|
*/
|
|
toggleLoading(show = false) {
|
|
const loading = document.querySelector('.loading-overlay');
|
|
if (loading) {
|
|
loading.classList.toggle('hidden', !show);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toast notification system dengan auto-remove
|
|
*/
|
|
showToast(message, type = 'info') {
|
|
const toast = this.createToastElement(message, type);
|
|
document.body.appendChild(toast);
|
|
|
|
const timer = setTimeout(() => {
|
|
this.removeToast(toast);
|
|
this.timers.delete(timer);
|
|
}, this.config.toastDuration);
|
|
|
|
this.timers.add(timer);
|
|
}
|
|
|
|
/**
|
|
* Create toast element
|
|
*/
|
|
createToastElement(message, type) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast toast-top toast-end';
|
|
toast.innerHTML = `
|
|
<div class="alert alert-${type}">
|
|
<span>${this.escapeHtml(message)}</span>
|
|
</div>
|
|
`;
|
|
return toast;
|
|
}
|
|
|
|
/**
|
|
* Remove toast dengan animation
|
|
*/
|
|
removeToast(toast) {
|
|
toast.style.opacity = '0';
|
|
toast.style.transform = 'translateX(100%)';
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* Escape HTML untuk security
|
|
*/
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Form validation dengan detailed feedback
|
|
*/
|
|
validateForm(formId) {
|
|
const form = document.getElementById(formId);
|
|
if (!form) {
|
|
console.warn(`Form dengan ID '${formId}' tidak ditemukan`);
|
|
return false;
|
|
}
|
|
|
|
const requiredInputs = form.querySelectorAll('input[required], select[required], textarea[required]');
|
|
let isValid = true;
|
|
|
|
requiredInputs.forEach(input => {
|
|
const isFieldValid = this.validateField(input);
|
|
if (!isFieldValid) {
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Validate individual field
|
|
*/
|
|
validateField(input) {
|
|
const isValid = input.value.trim() !== '';
|
|
input.classList.toggle('input-error', !isValid);
|
|
|
|
if (!isValid) {
|
|
this.showFieldError(input);
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Show field error
|
|
*/
|
|
showFieldError(input) {
|
|
const fieldName = input.getAttribute('placeholder') || input.getAttribute('name') || 'Field';
|
|
this.showToast(`${fieldName} harus diisi`, 'error');
|
|
}
|
|
|
|
/**
|
|
* Cleanup resources
|
|
*/
|
|
cleanup() {
|
|
// Clear all timers
|
|
this.timers.forEach(timer => clearInterval(timer));
|
|
this.timers.clear();
|
|
|
|
// Disconnect all observers
|
|
this.observers.forEach(observer => observer.disconnect());
|
|
this.observers.clear();
|
|
}
|
|
|
|
/**
|
|
* Initialize aplikasi
|
|
*/
|
|
init() {
|
|
try {
|
|
this.initSmoothScroll();
|
|
this.initScrollAnimations();
|
|
this.initCounterAnimation();
|
|
this.initThemeSwitch();
|
|
|
|
// Setup cleanup pada page unload
|
|
window.addEventListener('beforeunload', () => this.cleanup());
|
|
|
|
// Hide loading setelah load complete
|
|
window.addEventListener('load', () => this.toggleLoading(false));
|
|
|
|
console.log('🌱 Bank Sampah Digital - Aplikasi siap digunakan!');
|
|
} catch (error) {
|
|
console.error('Error initializing BpsRwApp:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create global instance
|
|
const BpsRw = new BpsRwApp();
|
|
|
|
// Initialize saat DOM ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
BpsRw.init();
|
|
});
|
|
|
|
// Export untuk penggunaan di file lain
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = BpsRw;
|
|
} |