/** * Modern Calculator - UX-polished service cost calculator * Production-ready calculator with a11y, dark mode, localStorage, live price updates */ class ModernCalculator { constructor() { this.state = { step: 0, selectedService: null, selectedComplexity: null, selectedTimeline: null, promoCode: '', darkMode: this.getStoredTheme() }; this.services = { web: { key: 'web', basePrice: 500000, name: 'Веб-разработка' }, mobile: { key: 'mobile', basePrice: 800000, name: 'Мобильные приложения' }, design: { key: 'design', basePrice: 300000, name: 'UI/UX Дизайн' }, marketing: { key: 'marketing', basePrice: 200000, name: 'Цифровой маркетинг' } }; this.complexity = { simple: { key: 'simple', multiplier: 1, name: 'Простой' }, medium: { key: 'medium', multiplier: 1.5, name: 'Средний' }, complex: { key: 'complex', multiplier: 2.5, name: 'Сложный' } }; this.timeline = { extended: { key: 'extended', multiplier: 0.8, name: 'Увеличенные сроки' }, standard: { key: 'standard', multiplier: 1, name: 'Стандартные сроки' }, rush: { key: 'rush', multiplier: 1.5, name: 'Срочно' } }; this.promoCodes = { 'HELLO10': 0.9, 'FRIENDS5': 0.95 }; this.storageKey = 'calculator_draft'; this.init(); } init() { console.log('Calculator initializing...'); this.loadFromStorage(); this.setupEventListeners(); this.updateUI(); this.applyTheme(); this.setupKeyboardNavigation(); // Force show price display for testing setTimeout(() => { console.log('Force showing price display...'); const priceDisplay = document.getElementById('priceDisplay'); if (priceDisplay) { priceDisplay.classList.remove('hidden'); console.log('Price display shown'); } else { console.log('Price display not found!'); } }, 1000); } // Storage management loadFromStorage() { try { const saved = localStorage.getItem(this.storageKey); if (saved) { const savedState = JSON.parse(saved); this.state = { ...this.state, ...savedState }; } } catch (e) { console.warn('Failed to load calculator state from localStorage'); } } saveToStorage() { try { localStorage.setItem(this.storageKey, JSON.stringify(this.state)); } catch (e) { console.warn('Failed to save calculator state to localStorage'); } } getStoredTheme() { const stored = localStorage.getItem('theme'); if (stored) return stored === 'dark'; return window.matchMedia('(prefers-color-scheme: dark)').matches; } // Theme management applyTheme() { document.documentElement.classList.toggle('dark', this.state.darkMode); localStorage.setItem('theme', this.state.darkMode ? 'dark' : 'light'); const toggle = document.getElementById('darkModeToggle'); if (toggle) toggle.checked = this.state.darkMode; } // Price calculation calculatePrice() { if (!this.state.selectedService) return 0; const basePrice = this.services[this.state.selectedService].basePrice; const complexityMultiplier = this.state.selectedComplexity ? this.complexity[this.state.selectedComplexity].multiplier : 1; const timelineMultiplier = this.state.selectedTimeline ? this.timeline[this.state.selectedTimeline].multiplier : 1; const promoMultiplier = this.promoCodes[this.state.promoCode.toUpperCase()] || 1; return Math.round(basePrice * complexityMultiplier * timelineMultiplier * promoMultiplier); } formatPrice(amount) { return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(amount); } // Translation helper t(key) { const translations = { 'calculator.result.estimated_price': 'Расчетная цена', 'calculator.complexity.simple': 'Простой', 'calculator.complexity.medium': 'Средний', 'calculator.complexity.complex': 'Сложный', 'calculator.timeline.extended': 'Увеличенные сроки', 'calculator.timeline.standard': 'Стандартные сроки', 'calculator.timeline.rush': 'Срочно', 'calculator.next_step': 'Далее', 'calculator.calculate': 'Рассчитать', 'services.web.title': 'Веб-разработка', 'services.mobile.title': 'Мобильные приложения', 'services.design.title': 'UI/UX Дизайн', 'services.marketing.title': 'Цифровой маркетинг' }; if (window.calculatorTranslations && window.calculatorTranslations[key]) { return window.calculatorTranslations[key]; } return translations[key] || key; } // UI Updates updatePriceDisplay() { const price = this.calculatePrice(); const formattedPrice = this.formatPrice(price); console.log('updatePriceDisplay called:', { price, formattedPrice, selectedService: this.state.selectedService }); // Update all price displays const priceElements = ['currentPrice', 'mobilePriceValue', 'finalPrice']; priceElements.forEach(id => { const element = document.getElementById(id); if (element) { element.textContent = formattedPrice; element.setAttribute('aria-live', 'polite'); } }); // Show/hide price displays const priceDisplay = document.getElementById('priceDisplay'); const mobilePriceDisplay = document.getElementById('mobilePriceDisplay'); console.log('Price display elements:', { priceDisplay, mobilePriceDisplay }); // Show price display if service is selected OR price > 0 if (this.state.selectedService || price > 0) { console.log('Showing price display'); if (priceDisplay) priceDisplay.classList.remove('hidden'); if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden'); } else { console.log('Hiding price display'); if (priceDisplay) priceDisplay.classList.add('hidden'); if (mobilePriceDisplay) mobilePriceDisplay.classList.add('hidden'); } } updateStepIndicators() { for (let i = 0; i < 3; i++) { const indicator = document.getElementById(`step-indicator-${i}`); const checkIcon = document.getElementById(`check-${i}`); const numberSpan = document.getElementById(`number-${i}`); const progressLine = document.getElementById(`progress-line-${i}`); if (!indicator) continue; // Reset classes indicator.className = 'w-12 h-12 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300'; if (i < this.state.step) { // Completed step indicator.classList.add('bg-green-500', 'text-white', 'shadow-lg'); if (checkIcon) { checkIcon.classList.remove('hidden'); numberSpan.classList.add('hidden'); } if (progressLine) progressLine.style.width = '100%'; } else if (i === this.state.step) { // Current step indicator.classList.add('bg-blue-600', 'text-white', 'shadow-lg'); if (checkIcon) checkIcon.classList.add('hidden'); if (numberSpan) numberSpan.classList.remove('hidden'); if (progressLine) progressLine.style.width = '0%'; } else { // Future step indicator.classList.add('bg-gray-300', 'dark:bg-gray-600', 'text-gray-600', 'dark:text-gray-400'); if (checkIcon) checkIcon.classList.add('hidden'); if (numberSpan) numberSpan.classList.remove('hidden'); if (progressLine) progressLine.style.width = '0%'; } } // Update overall progress const overallProgress = document.getElementById('overallProgress'); if (overallProgress) { const progressPercentage = ((this.state.step + 1) / 3) * 100; overallProgress.style.width = `${progressPercentage}%`; } } updateStepContent() { // Hide all steps document.querySelectorAll('.step-content').forEach(step => { step.classList.add('hidden'); }); // Show current step const currentStep = document.getElementById(`step-${this.state.step + 1}`); if (currentStep) { currentStep.classList.remove('hidden'); } // Update navigation buttons this.updateNavigationButtons(); } updateNavigationButtons() { const prevBtn = document.getElementById('prevBtn'); const nextBtn = document.getElementById('nextBtn'); const nextBtnText = document.getElementById('nextBtnText'); if (prevBtn) { if (this.state.step > 0) { prevBtn.classList.remove('hidden'); } else { prevBtn.classList.add('hidden'); } } if (nextBtn && nextBtnText) { if (this.state.step < 2) { nextBtn.classList.remove('hidden'); const canProceed = this.canProceedToNextStep(); nextBtn.disabled = !canProceed; if (this.state.step === 1) { nextBtnText.textContent = this.t('calculator.calculate'); } else { nextBtnText.textContent = this.t('calculator.next_step'); } } else { nextBtn.classList.add('hidden'); } } } canProceedToNextStep() { switch (this.state.step) { case 0: return !!this.state.selectedService; case 1: return !!this.state.selectedComplexity && !!this.state.selectedTimeline; case 2: return true; default: return false; } } updateSelectionUI() { // Update service selection document.querySelectorAll('.service-card').forEach(card => { const service = card.dataset.service; const indicator = card.querySelector('.service-indicator'); if (service === this.state.selectedService) { card.classList.add('selected'); if (indicator) indicator.classList.add('scale-100'); card.setAttribute('aria-pressed', 'true'); } else { card.classList.remove('selected'); if (indicator) indicator.classList.remove('scale-100'); card.setAttribute('aria-pressed', 'false'); } }); // Update complexity selection document.querySelectorAll('.complexity-card').forEach(card => { const complexity = card.dataset.complexity; const cardDiv = card.querySelector('div'); if (complexity === this.state.selectedComplexity) { cardDiv.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900'); cardDiv.classList.remove('border-transparent'); card.setAttribute('aria-pressed', 'true'); } else { cardDiv.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900'); cardDiv.classList.add('border-transparent'); card.setAttribute('aria-pressed', 'false'); } }); // Update timeline selection document.querySelectorAll('.timeline-card').forEach(card => { const timeline = card.dataset.timeline; const cardDiv = card.querySelector('div'); if (timeline === this.state.selectedTimeline) { cardDiv.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900'); cardDiv.classList.remove('border-transparent'); card.setAttribute('aria-pressed', 'true'); } else { cardDiv.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900'); cardDiv.classList.add('border-transparent'); card.setAttribute('aria-pressed', 'false'); } }); } updatePriceBreakdown() { const breakdown = document.getElementById('priceBreakdown'); if (!breakdown || this.state.step !== 2) return; breakdown.innerHTML = ''; if (!this.state.selectedService) return; const basePrice = this.services[this.state.selectedService].basePrice; const complexityMultiplier = this.state.selectedComplexity ? this.complexity[this.state.selectedComplexity].multiplier : 1; const timelineMultiplier = this.state.selectedTimeline ? this.timeline[this.state.selectedTimeline].multiplier : 1; const promoMultiplier = this.promoCodes[this.state.promoCode.toUpperCase()] || 1; const appliedPromo = this.promoCodes[this.state.promoCode.toUpperCase()] ? this.state.promoCode.toUpperCase() : ''; // Base price breakdown.appendChild(this.createPriceLineElement( 'Базовая стоимость', basePrice )); // Complexity adjustment if (complexityMultiplier !== 1) { breakdown.appendChild(this.createPriceLineElement( 'Сложность', basePrice * complexityMultiplier, `×${complexityMultiplier}` )); } // Timeline adjustment if (timelineMultiplier !== 1) { breakdown.appendChild(this.createPriceLineElement( 'Сроки', basePrice * complexityMultiplier * timelineMultiplier, `×${timelineMultiplier}` )); } // Promo code discount if (appliedPromo) { breakdown.appendChild(this.createPriceLineElement( 'Промокод', basePrice * complexityMultiplier * timelineMultiplier * promoMultiplier, appliedPromo )); } } createPriceLineElement(label, amount, badge = null) { const div = document.createElement('div'); div.className = 'flex justify-between items-center py-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0'; div.innerHTML = ` ${label} ${badge ? `${badge}` : ''} ${this.formatPrice(amount)} `; return div; } updateUI() { this.updateStepIndicators(); this.updateStepContent(); this.updateSelectionUI(); this.updatePriceDisplay(); this.updatePriceBreakdown(); this.saveToStorage(); } // Event handlers setupEventListeners() { // Reset button const resetButton = document.getElementById('resetButton'); if (resetButton) { resetButton.addEventListener('click', () => { this.resetCalculator(); }); } // Dark mode toggle const darkModeToggle = document.getElementById('darkModeToggle'); if (darkModeToggle) { darkModeToggle.addEventListener('change', () => { this.state.darkMode = darkModeToggle.checked; this.applyTheme(); this.saveToStorage(); }); } // Service selection document.querySelectorAll('.service-card').forEach(card => { card.addEventListener('click', () => { const service = card.dataset.service; this.state.selectedService = service; this.updateUI(); // Announce selection for screen readers this.announceForScreenReader( `Выбрана услуга ${this.services[service].name}` ); }); }); // Complexity selection document.querySelectorAll('.complexity-card').forEach(card => { card.addEventListener('click', () => { const complexity = card.dataset.complexity; this.state.selectedComplexity = complexity; this.updateUI(); }); }); // Timeline selection document.querySelectorAll('.timeline-card').forEach(card => { card.addEventListener('click', () => { const timeline = card.dataset.timeline; this.state.selectedTimeline = timeline; this.updateUI(); }); }); // Navigation buttons const prevBtn = document.getElementById('prevBtn'); const nextBtn = document.getElementById('nextBtn'); if (prevBtn) { prevBtn.addEventListener('click', () => { if (this.state.step > 0) { this.state.step--; this.updateUI(); } }); } if (nextBtn) { nextBtn.addEventListener('click', () => { if (this.state.step < 2 && this.canProceedToNextStep()) { this.state.step++; this.updateUI(); } }); } // Promo code const applyPromoBtn = document.getElementById('applyPromo'); if (applyPromoBtn) { applyPromoBtn.addEventListener('click', this.applyPromoCode.bind(this)); } const promoInput = document.getElementById('promoCode'); if (promoInput) { promoInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.applyPromoCode(); } }); } // Final actions const getQuoteBtn = document.getElementById('getQuoteBtn'); if (getQuoteBtn) { getQuoteBtn.addEventListener('click', this.submitQuote.bind(this)); } const recalculateBtn = document.getElementById('recalculateBtn'); if (recalculateBtn) { recalculateBtn.addEventListener('click', this.resetCalculator.bind(this)); } } setupKeyboardNavigation() { // Add keyboard support for card selections document.querySelectorAll('.service-card, .complexity-card, .timeline-card').forEach(card => { card.setAttribute('tabindex', '0'); card.setAttribute('role', 'button'); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); } }); }); } applyPromoCode() { const promoInput = document.getElementById('promoCode'); const promoStatus = document.getElementById('promoStatus'); if (!promoInput || !promoStatus) return; const code = promoInput.value.trim().toUpperCase(); if (this.promoCodes[code]) { this.state.promoCode = code; promoStatus.textContent = 'Промокод применен'; promoStatus.className = 'mt-2 text-sm text-green-600 dark:text-green-400'; this.updateUI(); } else if (code) { promoStatus.textContent = 'Неверный промокод'; promoStatus.className = 'mt-2 text-sm text-red-600 dark:text-red-400'; } else { this.state.promoCode = ''; promoStatus.textContent = ''; this.updateUI(); } } submitQuote() { const finalPrice = this.calculatePrice(); // In a real application, this would send data to the server alert(`Заявка отправлена!\n\nИтоговая стоимость: ${this.formatPrice(finalPrice)}\n\nМы свяжемся с вами в ближайшее время.`); // Clear the form this.resetCalculator(); } resetCalculator() { this.state = { step: 0, selectedService: null, selectedComplexity: null, selectedTimeline: null, promoCode: '', darkMode: this.state.darkMode }; // Clear promo code input and status const promoInput = document.getElementById('promoCode'); const promoStatus = document.getElementById('promoStatus'); if (promoInput) promoInput.value = ''; if (promoStatus) promoStatus.textContent = ''; this.updateUI(); } announceForScreenReader(message) { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } } // Initialize calculator when DOM is loaded document.addEventListener('DOMContentLoaded', () => { new ModernCalculator(); }); // Add CSS classes for screen reader only content const style = document.createElement('style'); style.textContent = ` .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .service-card.selected .service-indicator { transform: scale(1); } .service-card:focus, .complexity-card:focus, .timeline-card:focus { outline: 2px solid #3b82f6; outline-offset: 2px; } `; document.head.appendChild(style);