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;
 | 
						|
} |