/**
* 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() {
this.loadFromStorage();
this.setupEventListeners();
this.updateUI();
this.applyTheme();
this.setupKeyboardNavigation();
// Ensure price display is visible on page load
setTimeout(() => {
const priceDisplay = document.getElementById('priceDisplay');
if (priceDisplay) {
priceDisplay.classList.remove('hidden');
}
}, 500);
}
// 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);