378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
			
		
		
	
	
			378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
| /**
 | |
|  * Bank Sampah Digital - Main JavaScript File
 | |
|  * Optimized dengan modern patterns dan clean code principles
 | |
|  */
 | |
| 
 | |
| class BankSampahApp {
 | |
|     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 BankSampahApp:', error);
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Create global instance
 | |
| const BankSampah = new BankSampahApp();
 | |
| 
 | |
| // Initialize saat DOM ready
 | |
| document.addEventListener('DOMContentLoaded', function() {
 | |
|     BankSampah.init();
 | |
| });
 | |
| 
 | |
| // Export untuk penggunaan di file lain
 | |
| if (typeof module !== 'undefined' && module.exports) {
 | |
|     module.exports = BankSampah;
 | |
| } |