Files
sst_site/public/js/calculator-modern.js
2025-10-26 14:44:10 +09:00

676 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
// Show sticky price display on page load
setTimeout(() => {
const stickyContainer = document.getElementById('stickyPriceContainer');
if (stickyContainer) {
stickyContainer.style.display = 'block';
}
}, 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);
// Update main price displays
const priceElements = ['currentPrice', 'finalPrice'];
priceElements.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.textContent = formattedPrice;
element.setAttribute('aria-live', 'polite');
}
});
// Update detailed breakdown
this.updatePriceBreakdown();
// Show sticky price display
const stickyContainer = document.getElementById('stickyPriceContainer');
if (stickyContainer) {
stickyContainer.style.display = 'block';
}
}
updatePriceBreakdown() {
// Service selection
const selectedServiceDiv = document.getElementById('selectedService');
const serviceName = document.getElementById('serviceName');
const servicePrice = document.getElementById('servicePrice');
if (this.state.selectedService && selectedServiceDiv && serviceName && servicePrice) {
const service = this.services[this.state.selectedService];
serviceName.textContent = service.name;
servicePrice.textContent = this.formatPrice(service.basePrice);
selectedServiceDiv.classList.remove('hidden');
} else if (selectedServiceDiv) {
selectedServiceDiv.classList.add('hidden');
}
// Complexity
const selectedComplexityDiv = document.getElementById('selectedComplexity');
const complexityName = document.getElementById('complexityName');
const complexityMultiplier = document.getElementById('complexityMultiplier');
if (this.state.selectedComplexity && selectedComplexityDiv && complexityName && complexityMultiplier) {
const complexity = this.complexity[this.state.selectedComplexity];
complexityName.textContent = complexity.name;
complexityMultiplier.textContent = complexity.multiplier;
selectedComplexityDiv.classList.remove('hidden');
} else if (selectedComplexityDiv) {
selectedComplexityDiv.classList.add('hidden');
}
// Timeline
const selectedTimelineDiv = document.getElementById('selectedTimeline');
const timelineName = document.getElementById('timelineName');
const timelineMultiplier = document.getElementById('timelineMultiplier');
if (this.state.selectedTimeline && selectedTimelineDiv && timelineName && timelineMultiplier) {
const timeline = this.timeline[this.state.selectedTimeline];
timelineName.textContent = timeline.name;
timelineMultiplier.textContent = timeline.multiplier;
selectedTimelineDiv.classList.remove('hidden');
} else if (selectedTimelineDiv) {
selectedTimelineDiv.classList.add('hidden');
}
// Promo code
const appliedPromoDiv = document.getElementById('appliedPromo');
const promoCode = document.getElementById('promoCode');
const promoDiscount = document.getElementById('promoDiscount');
if (this.state.promoCode && this.promoCodes[this.state.promoCode.toUpperCase()] && appliedPromoDiv && promoCode && promoDiscount) {
const discount = this.promoCodes[this.state.promoCode.toUpperCase()];
const discountPercent = Math.round((1 - discount) * 100);
promoCode.textContent = this.state.promoCode.toUpperCase();
promoDiscount.textContent = `-${discountPercent}%`;
appliedPromoDiv.classList.remove('hidden');
} else if (appliedPromoDiv) {
appliedPromoDiv.classList.add('hidden');
}
// Final calculation
const finalCalculation = document.getElementById('finalCalculation');
if (finalCalculation) {
const price = this.calculatePrice();
finalCalculation.textContent = this.formatPrice(price);
}
}
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 = `
<span class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
${label}
${badge ? `<span class="text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded-full">${badge}</span>` : ''}
</span>
<span class="font-semibold text-gray-900 dark:text-white">${this.formatPrice(amount)}</span>
`;
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);