Main functions
This commit is contained in:
641
.history/public/js/calculator-modern_20251026100125.js
Normal file
641
.history/public/js/calculator-modern_20251026100125.js
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
if (this.state.selectedService) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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.updatePriceIsland(); // Add integration with price island
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
// New method to update price island
|
||||
updatePriceIsland() {
|
||||
if (window.priceIsland) {
|
||||
const selectedServices = [];
|
||||
|
||||
// Add selected service
|
||||
if (this.state.selectedService) {
|
||||
const service = this.services[this.state.selectedService];
|
||||
selectedServices.push({
|
||||
name: service.name,
|
||||
price: service.basePrice
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare data for island
|
||||
const islandData = {
|
||||
selectedServices: selectedServices,
|
||||
complexity: this.state.selectedComplexity ? {
|
||||
name: this.complexity[this.state.selectedComplexity].name,
|
||||
multiplier: this.complexity[this.state.selectedComplexity].multiplier
|
||||
} : null,
|
||||
timeline: this.state.selectedTimeline ? {
|
||||
name: this.timeline[this.state.selectedTimeline].name,
|
||||
multiplier: this.timeline[this.state.selectedTimeline].multiplier
|
||||
} : null,
|
||||
totalPrice: this.calculatePrice()
|
||||
};
|
||||
|
||||
// Dispatch event to update island
|
||||
document.dispatchEvent(new CustomEvent('calculatorStateChange', {
|
||||
detail: islandData
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
641
.history/public/js/calculator-modern_20251026100440.js
Normal file
641
.history/public/js/calculator-modern_20251026100440.js
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
if (this.state.selectedService) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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.updatePriceIsland(); // Add integration with price island
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
// New method to update price island
|
||||
updatePriceIsland() {
|
||||
if (window.priceIsland) {
|
||||
const selectedServices = [];
|
||||
|
||||
// Add selected service
|
||||
if (this.state.selectedService) {
|
||||
const service = this.services[this.state.selectedService];
|
||||
selectedServices.push({
|
||||
name: service.name,
|
||||
price: service.basePrice
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare data for island
|
||||
const islandData = {
|
||||
selectedServices: selectedServices,
|
||||
complexity: this.state.selectedComplexity ? {
|
||||
name: this.complexity[this.state.selectedComplexity].name,
|
||||
multiplier: this.complexity[this.state.selectedComplexity].multiplier
|
||||
} : null,
|
||||
timeline: this.state.selectedTimeline ? {
|
||||
name: this.timeline[this.state.selectedTimeline].name,
|
||||
multiplier: this.timeline[this.state.selectedTimeline].multiplier
|
||||
} : null,
|
||||
totalPrice: this.calculatePrice()
|
||||
};
|
||||
|
||||
// Dispatch event to update island
|
||||
document.dispatchEvent(new CustomEvent('calculatorStateChange', {
|
||||
detail: islandData
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
605
.history/public/js/calculator-modern_20251026100647.js
Normal file
605
.history/public/js/calculator-modern_20251026100647.js
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
if (this.state.selectedService) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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);
|
||||
605
.history/public/js/calculator-modern_20251026100722.js
Normal file
605
.history/public/js/calculator-modern_20251026100722.js
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
if (this.state.selectedService) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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);
|
||||
606
.history/public/js/calculator-modern_20251026101525.js
Normal file
606
.history/public/js/calculator-modern_20251026101525.js
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
// Show price display if service is selected OR price > 0
|
||||
if (this.state.selectedService || price > 0) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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);
|
||||
606
.history/public/js/calculator-modern_20251026101528.js
Normal file
606
.history/public/js/calculator-modern_20251026101528.js
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 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');
|
||||
|
||||
// Show price display if service is selected OR price > 0
|
||||
if (this.state.selectedService || price > 0) {
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
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 = `
|
||||
<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);
|
||||
616
.history/public/js/calculator-modern_20251026101548.js
Normal file
616
.history/public/js/calculator-modern_20251026101548.js
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<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);
|
||||
629
.history/public/js/calculator-modern_20251026101557.js
Normal file
629
.history/public/js/calculator-modern_20251026101557.js
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<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);
|
||||
624
.history/public/js/calculator-modern_20251026101633.js
Normal file
624
.history/public/js/calculator-modern_20251026101633.js
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<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);
|
||||
608
.history/public/js/calculator-modern_20251026101646.js
Normal file
608
.history/public/js/calculator-modern_20251026101646.js
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Always show price display once initialized
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('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 = `
|
||||
<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);
|
||||
608
.history/public/js/calculator-modern_20251026101730.js
Normal file
608
.history/public/js/calculator-modern_20251026101730.js
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Always show price display once initialized
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
if (priceDisplay) priceDisplay.classList.remove('hidden');
|
||||
if (mobilePriceDisplay) mobilePriceDisplay.classList.remove('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 = `
|
||||
<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);
|
||||
612
.history/public/js/calculator-modern_20251026102039.js
Normal file
612
.history/public/js/calculator-modern_20251026102039.js
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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 sticky price display
|
||||
const stickyContainer = document.getElementById('stickyPriceContainer');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
if (stickyContainer) {
|
||||
stickyContainer.style.display = 'block';
|
||||
}
|
||||
if (mobilePriceDisplay) {
|
||||
mobilePriceDisplay.classList.remove('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 = `
|
||||
<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);
|
||||
612
.history/public/js/calculator-modern_20251026102048.js
Normal file
612
.history/public/js/calculator-modern_20251026102048.js
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* 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 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 sticky price display
|
||||
const stickyContainer = document.getElementById('stickyPriceContainer');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
if (stickyContainer) {
|
||||
stickyContainer.style.display = 'block';
|
||||
}
|
||||
if (mobilePriceDisplay) {
|
||||
mobilePriceDisplay.classList.remove('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 = `
|
||||
<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);
|
||||
608
.history/public/js/calculator-modern_20251026102220.js
Normal file
608
.history/public/js/calculator-modern_20251026102220.js
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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 all price displays (remove mobilePriceValue since we removed mobile block)
|
||||
const priceElements = ['currentPrice', 'finalPrice'];
|
||||
priceElements.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.textContent = formattedPrice;
|
||||
element.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
});
|
||||
|
||||
// Show sticky price display
|
||||
const stickyContainer = document.getElementById('stickyPriceContainer');
|
||||
|
||||
if (stickyContainer) {
|
||||
stickyContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
608
.history/public/js/calculator-modern_20251026102247.js
Normal file
608
.history/public/js/calculator-modern_20251026102247.js
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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 all price displays (remove mobilePriceValue since we removed mobile block)
|
||||
const priceElements = ['currentPrice', 'finalPrice'];
|
||||
priceElements.forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.textContent = formattedPrice;
|
||||
element.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
});
|
||||
|
||||
// Show sticky price display
|
||||
const stickyContainer = document.getElementById('stickyPriceContainer');
|
||||
|
||||
if (stickyContainer) {
|
||||
stickyContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
676
.history/public/js/calculator-modern_20251026103724.js
Normal file
676
.history/public/js/calculator-modern_20251026103724.js
Normal file
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* 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);
|
||||
676
.history/public/js/calculator-modern_20251026104239.js
Normal file
676
.history/public/js/calculator-modern_20251026104239.js
Normal file
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -1,368 +0,0 @@
|
||||
// Calculator Logic
|
||||
class ProjectCalculator {
|
||||
constructor() {
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
this.totalSteps = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Service selection
|
||||
document.querySelectorAll('.service-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleServiceSelection(e));
|
||||
});
|
||||
|
||||
// Complexity selection
|
||||
document.querySelectorAll('.complexity-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleComplexitySelection(e));
|
||||
});
|
||||
|
||||
// Timeline selection
|
||||
document.querySelectorAll('.timeline-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleTimelineSelection(e));
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.querySelectorAll('.next-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.nextStep());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.prevStep());
|
||||
});
|
||||
|
||||
// Restart button
|
||||
const restartBtn = document.querySelector('.restart-calculator');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => this.restart());
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
const service = option.dataset.service;
|
||||
const price = parseInt(option.dataset.basePrice);
|
||||
|
||||
option.classList.toggle('selected');
|
||||
|
||||
if (option.classList.contains('selected')) {
|
||||
this.selectedServices.push({ service, price });
|
||||
this.animateSelection(option, true);
|
||||
} else {
|
||||
this.selectedServices = this.selectedServices.filter(s => s.service !== service);
|
||||
this.animateSelection(option, false);
|
||||
}
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleComplexitySelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.complexity-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedComplexity = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleTimelineSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedTimeline = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
animateSelection(element, selected) {
|
||||
if (selected) {
|
||||
element.style.borderColor = '#3B82F6';
|
||||
element.style.backgroundColor = '#EBF8FF';
|
||||
element.style.transform = 'translateY(-2px)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.15)';
|
||||
} else {
|
||||
element.style.borderColor = '';
|
||||
element.style.backgroundColor = '';
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateStepButton() {
|
||||
const step1Button = document.querySelector('#step-1 .next-step');
|
||||
const step2Button = document.querySelector('#step-2 .next-step');
|
||||
|
||||
if (step1Button) {
|
||||
step1Button.disabled = this.selectedServices.length === 0;
|
||||
step1Button.style.opacity = this.selectedServices.length > 0 ? '1' : '0.6';
|
||||
}
|
||||
|
||||
if (step2Button) {
|
||||
const isValid = this.selectedComplexity && this.selectedTimeline;
|
||||
step2Button.disabled = !isValid;
|
||||
step2Button.style.opacity = isValid ? '1' : '0.6';
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progressBar = document.querySelector('.calculator-progress-bar');
|
||||
if (progressBar) {
|
||||
const progress = (this.currentStep / this.totalSteps) * 100;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.className = `calculator-progress-bar step-${this.currentStep}`;
|
||||
}
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep >= this.totalSteps) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const nextStepElement = currentStepElement.nextElementSibling;
|
||||
|
||||
if (nextStepElement && nextStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in next step
|
||||
nextStepElement.classList.add('active');
|
||||
nextStepElement.style.display = 'block';
|
||||
nextStepElement.style.opacity = '0';
|
||||
nextStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
nextStepElement.style.opacity = '1';
|
||||
nextStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep++;
|
||||
this.updateProgressBar();
|
||||
|
||||
if (this.currentStep === 3) {
|
||||
setTimeout(() => this.calculateFinalPrice(), 300);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep <= 1) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const prevStepElement = currentStepElement.previousElementSibling;
|
||||
|
||||
if (prevStepElement && prevStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in previous step
|
||||
prevStepElement.classList.add('active');
|
||||
prevStepElement.style.display = 'block';
|
||||
prevStepElement.style.opacity = '0';
|
||||
prevStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
prevStepElement.style.opacity = '1';
|
||||
prevStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep--;
|
||||
this.updateProgressBar();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
calculateFinalPrice() {
|
||||
let total = 0;
|
||||
|
||||
// Calculate base price from services
|
||||
this.selectedServices.forEach(service => {
|
||||
total += service.price;
|
||||
});
|
||||
|
||||
// Apply complexity multiplier
|
||||
if (this.selectedComplexity) {
|
||||
total *= this.selectedComplexity.multiplier;
|
||||
}
|
||||
|
||||
// Apply timeline multiplier
|
||||
if (this.selectedTimeline) {
|
||||
total *= this.selectedTimeline.multiplier;
|
||||
}
|
||||
|
||||
// Animate price reveal
|
||||
const priceElement = document.getElementById('final-price');
|
||||
if (priceElement) {
|
||||
priceElement.style.opacity = '0';
|
||||
priceElement.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
priceElement.textContent = '₩' + total.toLocaleString();
|
||||
priceElement.style.opacity = '1';
|
||||
priceElement.style.transform = 'scale(1)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
setTimeout(() => this.generateSummary(total), 600);
|
||||
}
|
||||
|
||||
generateSummary(total) {
|
||||
const summary = document.getElementById('project-summary');
|
||||
if (!summary) return;
|
||||
|
||||
let summaryHTML = '';
|
||||
|
||||
// Services
|
||||
summaryHTML += '<div class="mb-4"><strong>Selected Services:</strong></div>';
|
||||
this.selectedServices.forEach(service => {
|
||||
summaryHTML += `<div class="ml-4 mb-2 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
${this.getServiceName(service.service)}
|
||||
<span class="ml-auto font-semibold">₩${service.price.toLocaleString()}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Complexity
|
||||
if (this.selectedComplexity) {
|
||||
summaryHTML += `<div class="mt-4 mb-2 flex items-center">
|
||||
<i class="fas fa-layer-group text-blue-500 mr-2"></i>
|
||||
<strong>Complexity:</strong>
|
||||
<span class="ml-2">${this.getComplexityName(this.selectedComplexity.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedComplexity.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (this.selectedTimeline) {
|
||||
summaryHTML += `<div class="mb-2 flex items-center">
|
||||
<i class="fas fa-clock text-purple-500 mr-2"></i>
|
||||
<strong>Timeline:</strong>
|
||||
<span class="ml-2">${this.getTimelineName(this.selectedTimeline.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedTimeline.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Animate summary appearance
|
||||
summary.style.opacity = '0';
|
||||
summary.innerHTML = summaryHTML;
|
||||
|
||||
setTimeout(() => {
|
||||
summary.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getServiceName(service) {
|
||||
const names = {
|
||||
web: 'Web Development',
|
||||
mobile: 'Mobile App',
|
||||
design: 'UI/UX Design',
|
||||
marketing: 'Digital Marketing'
|
||||
};
|
||||
return names[service] || service;
|
||||
}
|
||||
|
||||
getComplexityName(complexity) {
|
||||
const names = {
|
||||
simple: 'Simple',
|
||||
medium: 'Medium',
|
||||
complex: 'Complex'
|
||||
};
|
||||
return names[complexity] || complexity;
|
||||
}
|
||||
|
||||
getTimelineName(timeline) {
|
||||
const names = {
|
||||
standard: 'Standard',
|
||||
rush: 'Rush',
|
||||
extended: 'Extended'
|
||||
};
|
||||
return names[timeline] || timeline;
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Reset all selections
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.service-option, .complexity-option, .timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Reset steps
|
||||
document.querySelectorAll('.calculator-step').forEach(step => {
|
||||
step.classList.remove('active');
|
||||
step.style.display = 'none';
|
||||
step.style.opacity = '1';
|
||||
step.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
// Show first step
|
||||
const firstStep = document.getElementById('step-1');
|
||||
if (firstStep) {
|
||||
firstStep.classList.add('active');
|
||||
firstStep.style.display = 'block';
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
this.updateStepButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme initialization
|
||||
const theme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
||||
|
||||
// Initialize calculator
|
||||
if (document.querySelector('.calculator-step')) {
|
||||
new ProjectCalculator();
|
||||
}
|
||||
});
|
||||
@@ -1,380 +0,0 @@
|
||||
// Calculator Logic
|
||||
class ProjectCalculator {
|
||||
constructor() {
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
this.totalSteps = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Service selection
|
||||
document.querySelectorAll('.service-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleServiceSelection(e));
|
||||
});
|
||||
|
||||
// Complexity selection
|
||||
document.querySelectorAll('.complexity-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleComplexitySelection(e));
|
||||
});
|
||||
|
||||
// Timeline selection
|
||||
document.querySelectorAll('.timeline-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleTimelineSelection(e));
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.querySelectorAll('.next-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.nextStep());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.prevStep());
|
||||
});
|
||||
|
||||
// Restart button
|
||||
const restartBtn = document.querySelector('.restart-calculator');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => this.restart());
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
const service = option.dataset.service;
|
||||
const price = parseInt(option.dataset.basePrice);
|
||||
|
||||
option.classList.toggle('selected');
|
||||
|
||||
if (option.classList.contains('selected')) {
|
||||
this.selectedServices.push({ service, price });
|
||||
this.animateSelection(option, true);
|
||||
} else {
|
||||
this.selectedServices = this.selectedServices.filter(s => s.service !== service);
|
||||
this.animateSelection(option, false);
|
||||
}
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleComplexitySelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.complexity-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedComplexity = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleTimelineSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedTimeline = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
animateSelection(element, selected) {
|
||||
if (selected) {
|
||||
element.style.borderColor = '#3B82F6';
|
||||
element.style.backgroundColor = '#EBF8FF';
|
||||
element.style.transform = 'translateY(-2px)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.15)';
|
||||
} else {
|
||||
element.style.borderColor = '';
|
||||
element.style.backgroundColor = '';
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateStepButton() {
|
||||
const step1Button = document.querySelector('#step-1 .next-step');
|
||||
const step2Button = document.querySelector('#step-2 .next-step');
|
||||
|
||||
if (step1Button) {
|
||||
step1Button.disabled = this.selectedServices.length === 0;
|
||||
step1Button.style.opacity = this.selectedServices.length > 0 ? '1' : '0.6';
|
||||
}
|
||||
|
||||
if (step2Button) {
|
||||
const isValid = this.selectedComplexity && this.selectedTimeline;
|
||||
step2Button.disabled = !isValid;
|
||||
step2Button.style.opacity = isValid ? '1' : '0.6';
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progressBar = document.querySelector('.calculator-progress-bar');
|
||||
if (progressBar) {
|
||||
const progress = (this.currentStep / this.totalSteps) * 100;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.className = `calculator-progress-bar step-${this.currentStep}`;
|
||||
}
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep >= this.totalSteps) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const nextStepElement = currentStepElement.nextElementSibling;
|
||||
|
||||
if (nextStepElement && nextStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in next step
|
||||
nextStepElement.classList.add('active');
|
||||
nextStepElement.style.display = 'block';
|
||||
nextStepElement.style.opacity = '0';
|
||||
nextStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
nextStepElement.style.opacity = '1';
|
||||
nextStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep++;
|
||||
this.updateProgressBar();
|
||||
|
||||
if (this.currentStep === 3) {
|
||||
setTimeout(() => this.calculateFinalPrice(), 300);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep <= 1) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const prevStepElement = currentStepElement.previousElementSibling;
|
||||
|
||||
if (prevStepElement && prevStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in previous step
|
||||
prevStepElement.classList.add('active');
|
||||
prevStepElement.style.display = 'block';
|
||||
prevStepElement.style.opacity = '0';
|
||||
prevStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
prevStepElement.style.opacity = '1';
|
||||
prevStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep--;
|
||||
this.updateProgressBar();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
calculateFinalPrice() {
|
||||
let total = 0;
|
||||
|
||||
// Calculate base price from services
|
||||
this.selectedServices.forEach(service => {
|
||||
total += service.price;
|
||||
});
|
||||
|
||||
// Apply complexity multiplier
|
||||
if (this.selectedComplexity) {
|
||||
total *= this.selectedComplexity.multiplier;
|
||||
}
|
||||
|
||||
// Apply timeline multiplier
|
||||
if (this.selectedTimeline) {
|
||||
total *= this.selectedTimeline.multiplier;
|
||||
}
|
||||
|
||||
// Animate price reveal
|
||||
const priceElement = document.getElementById('final-price');
|
||||
if (priceElement) {
|
||||
priceElement.style.opacity = '0';
|
||||
priceElement.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
priceElement.textContent = '₩' + total.toLocaleString();
|
||||
priceElement.style.opacity = '1';
|
||||
priceElement.style.transform = 'scale(1)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
setTimeout(() => this.generateSummary(total), 600);
|
||||
}
|
||||
|
||||
generateSummary(total) {
|
||||
const summary = document.getElementById('project-summary');
|
||||
if (!summary) return;
|
||||
|
||||
let summaryHTML = '';
|
||||
|
||||
// Services
|
||||
summaryHTML += '<div class="mb-4"><strong>Selected Services:</strong></div>';
|
||||
this.selectedServices.forEach(service => {
|
||||
summaryHTML += `<div class="ml-4 mb-2 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
${this.getServiceName(service.service)}
|
||||
<span class="ml-auto font-semibold">₩${service.price.toLocaleString()}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Complexity
|
||||
if (this.selectedComplexity) {
|
||||
summaryHTML += `<div class="mt-4 mb-2 flex items-center">
|
||||
<i class="fas fa-layer-group text-blue-500 mr-2"></i>
|
||||
<strong>Complexity:</strong>
|
||||
<span class="ml-2">${this.getComplexityName(this.selectedComplexity.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedComplexity.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (this.selectedTimeline) {
|
||||
summaryHTML += `<div class="mb-2 flex items-center">
|
||||
<i class="fas fa-clock text-purple-500 mr-2"></i>
|
||||
<strong>Timeline:</strong>
|
||||
<span class="ml-2">${this.getTimelineName(this.selectedTimeline.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedTimeline.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Animate summary appearance
|
||||
summary.style.opacity = '0';
|
||||
summary.innerHTML = summaryHTML;
|
||||
|
||||
setTimeout(() => {
|
||||
summary.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getServiceName(service) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.services) {
|
||||
return window.calculatorTranslations.services[service] || service;
|
||||
}
|
||||
|
||||
const names = {
|
||||
web: 'Web Development',
|
||||
mobile: 'Mobile App',
|
||||
design: 'UI/UX Design',
|
||||
marketing: 'Digital Marketing'
|
||||
};
|
||||
return names[service] || service;
|
||||
}
|
||||
|
||||
getComplexityName(complexity) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.complexity) {
|
||||
return window.calculatorTranslations.complexity[complexity] || complexity;
|
||||
}
|
||||
|
||||
const names = {
|
||||
simple: 'Simple',
|
||||
medium: 'Medium',
|
||||
complex: 'Complex'
|
||||
};
|
||||
return names[complexity] || complexity;
|
||||
}
|
||||
|
||||
getTimelineName(timeline) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.timeline) {
|
||||
return window.calculatorTranslations.timeline[timeline] || timeline;
|
||||
}
|
||||
|
||||
const names = {
|
||||
standard: 'Standard',
|
||||
rush: 'Rush',
|
||||
extended: 'Extended'
|
||||
};
|
||||
return names[timeline] || timeline;
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Reset all selections
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.service-option, .complexity-option, .timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Reset steps
|
||||
document.querySelectorAll('.calculator-step').forEach(step => {
|
||||
step.classList.remove('active');
|
||||
step.style.display = 'none';
|
||||
step.style.opacity = '1';
|
||||
step.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
// Show first step
|
||||
const firstStep = document.getElementById('step-1');
|
||||
if (firstStep) {
|
||||
firstStep.classList.add('active');
|
||||
firstStep.style.display = 'block';
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
this.updateStepButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme initialization
|
||||
const theme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
||||
|
||||
// Initialize calculator
|
||||
if (document.querySelector('.calculator-step')) {
|
||||
new ProjectCalculator();
|
||||
}
|
||||
});
|
||||
@@ -1,384 +0,0 @@
|
||||
// Calculator Logic
|
||||
class ProjectCalculator {
|
||||
constructor() {
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
this.totalSteps = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Service selection
|
||||
document.querySelectorAll('.service-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleServiceSelection(e));
|
||||
});
|
||||
|
||||
// Complexity selection
|
||||
document.querySelectorAll('.complexity-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleComplexitySelection(e));
|
||||
});
|
||||
|
||||
// Timeline selection
|
||||
document.querySelectorAll('.timeline-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleTimelineSelection(e));
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.querySelectorAll('.next-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.nextStep());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.prevStep());
|
||||
});
|
||||
|
||||
// Restart button
|
||||
const restartBtn = document.querySelector('.restart-calculator');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => this.restart());
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
const service = option.dataset.service;
|
||||
const price = parseInt(option.dataset.basePrice);
|
||||
|
||||
option.classList.toggle('selected');
|
||||
|
||||
if (option.classList.contains('selected')) {
|
||||
this.selectedServices.push({ service, price });
|
||||
this.animateSelection(option, true);
|
||||
} else {
|
||||
this.selectedServices = this.selectedServices.filter(s => s.service !== service);
|
||||
this.animateSelection(option, false);
|
||||
}
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleComplexitySelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.complexity-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedComplexity = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleTimelineSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedTimeline = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
animateSelection(element, selected) {
|
||||
if (selected) {
|
||||
element.style.borderColor = '#3B82F6';
|
||||
element.style.backgroundColor = '#EBF8FF';
|
||||
element.style.transform = 'translateY(-2px)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.15)';
|
||||
} else {
|
||||
element.style.borderColor = '';
|
||||
element.style.backgroundColor = '';
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateStepButton() {
|
||||
const step1Button = document.querySelector('#step-1 .next-step');
|
||||
const step2Button = document.querySelector('#step-2 .next-step');
|
||||
|
||||
if (step1Button) {
|
||||
step1Button.disabled = this.selectedServices.length === 0;
|
||||
step1Button.style.opacity = this.selectedServices.length > 0 ? '1' : '0.6';
|
||||
}
|
||||
|
||||
if (step2Button) {
|
||||
const isValid = this.selectedComplexity && this.selectedTimeline;
|
||||
step2Button.disabled = !isValid;
|
||||
step2Button.style.opacity = isValid ? '1' : '0.6';
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progressBar = document.querySelector('.calculator-progress-bar');
|
||||
if (progressBar) {
|
||||
const progress = (this.currentStep / this.totalSteps) * 100;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.className = `calculator-progress-bar step-${this.currentStep}`;
|
||||
}
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep >= this.totalSteps) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const nextStepElement = currentStepElement.nextElementSibling;
|
||||
|
||||
if (nextStepElement && nextStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in next step
|
||||
nextStepElement.classList.add('active');
|
||||
nextStepElement.style.display = 'block';
|
||||
nextStepElement.style.opacity = '0';
|
||||
nextStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
nextStepElement.style.opacity = '1';
|
||||
nextStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep++;
|
||||
this.updateProgressBar();
|
||||
|
||||
if (this.currentStep === 3) {
|
||||
setTimeout(() => this.calculateFinalPrice(), 300);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep <= 1) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const prevStepElement = currentStepElement.previousElementSibling;
|
||||
|
||||
if (prevStepElement && prevStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in previous step
|
||||
prevStepElement.classList.add('active');
|
||||
prevStepElement.style.display = 'block';
|
||||
prevStepElement.style.opacity = '0';
|
||||
prevStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
prevStepElement.style.opacity = '1';
|
||||
prevStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep--;
|
||||
this.updateProgressBar();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
calculateFinalPrice() {
|
||||
let total = 0;
|
||||
|
||||
// Calculate base price from services
|
||||
this.selectedServices.forEach(service => {
|
||||
total += service.price;
|
||||
});
|
||||
|
||||
// Apply complexity multiplier
|
||||
if (this.selectedComplexity) {
|
||||
total *= this.selectedComplexity.multiplier;
|
||||
}
|
||||
|
||||
// Apply timeline multiplier
|
||||
if (this.selectedTimeline) {
|
||||
total *= this.selectedTimeline.multiplier;
|
||||
}
|
||||
|
||||
// Animate price reveal
|
||||
const priceElement = document.getElementById('final-price');
|
||||
if (priceElement) {
|
||||
priceElement.style.opacity = '0';
|
||||
priceElement.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
priceElement.textContent = '₩' + total.toLocaleString();
|
||||
priceElement.style.opacity = '1';
|
||||
priceElement.style.transform = 'scale(1)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
setTimeout(() => this.generateSummary(total), 600);
|
||||
}
|
||||
|
||||
generateSummary(total) {
|
||||
const summary = document.getElementById('project-summary');
|
||||
if (!summary) return;
|
||||
|
||||
let summaryHTML = '';
|
||||
|
||||
// Services
|
||||
const servicesLabel = window.calculatorTranslations?.labels?.selected_services || 'Selected Services';
|
||||
const complexityLabel = window.calculatorTranslations?.labels?.complexity || 'Complexity';
|
||||
const timelineLabel = window.calculatorTranslations?.labels?.timeline || 'Timeline';
|
||||
|
||||
summaryHTML += `<div class="mb-4"><strong>${servicesLabel}:</strong></div>`;
|
||||
this.selectedServices.forEach(service => {
|
||||
summaryHTML += `<div class="ml-4 mb-2 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
${this.getServiceName(service.service)}
|
||||
<span class="ml-auto font-semibold">₩${service.price.toLocaleString()}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Complexity
|
||||
if (this.selectedComplexity) {
|
||||
summaryHTML += `<div class="mt-4 mb-2 flex items-center">
|
||||
<i class="fas fa-layer-group text-blue-500 mr-2"></i>
|
||||
<strong>${complexityLabel}:</strong>
|
||||
<span class="ml-2">${this.getComplexityName(this.selectedComplexity.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedComplexity.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (this.selectedTimeline) {
|
||||
summaryHTML += `<div class="mb-2 flex items-center">
|
||||
<i class="fas fa-clock text-purple-500 mr-2"></i>
|
||||
<strong>${timelineLabel}:</strong>
|
||||
<span class="ml-2">${this.getTimelineName(this.selectedTimeline.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedTimeline.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Animate summary appearance
|
||||
summary.style.opacity = '0';
|
||||
summary.innerHTML = summaryHTML;
|
||||
|
||||
setTimeout(() => {
|
||||
summary.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getServiceName(service) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.services) {
|
||||
return window.calculatorTranslations.services[service] || service;
|
||||
}
|
||||
|
||||
const names = {
|
||||
web: 'Web Development',
|
||||
mobile: 'Mobile App',
|
||||
design: 'UI/UX Design',
|
||||
marketing: 'Digital Marketing'
|
||||
};
|
||||
return names[service] || service;
|
||||
}
|
||||
|
||||
getComplexityName(complexity) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.complexity) {
|
||||
return window.calculatorTranslations.complexity[complexity] || complexity;
|
||||
}
|
||||
|
||||
const names = {
|
||||
simple: 'Simple',
|
||||
medium: 'Medium',
|
||||
complex: 'Complex'
|
||||
};
|
||||
return names[complexity] || complexity;
|
||||
}
|
||||
|
||||
getTimelineName(timeline) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.timeline) {
|
||||
return window.calculatorTranslations.timeline[timeline] || timeline;
|
||||
}
|
||||
|
||||
const names = {
|
||||
standard: 'Standard',
|
||||
rush: 'Rush',
|
||||
extended: 'Extended'
|
||||
};
|
||||
return names[timeline] || timeline;
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Reset all selections
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.service-option, .complexity-option, .timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Reset steps
|
||||
document.querySelectorAll('.calculator-step').forEach(step => {
|
||||
step.classList.remove('active');
|
||||
step.style.display = 'none';
|
||||
step.style.opacity = '1';
|
||||
step.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
// Show first step
|
||||
const firstStep = document.getElementById('step-1');
|
||||
if (firstStep) {
|
||||
firstStep.classList.add('active');
|
||||
firstStep.style.display = 'block';
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
this.updateStepButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme initialization
|
||||
const theme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
||||
|
||||
// Initialize calculator
|
||||
if (document.querySelector('.calculator-step')) {
|
||||
new ProjectCalculator();
|
||||
}
|
||||
});
|
||||
@@ -1,384 +0,0 @@
|
||||
// Calculator Logic
|
||||
class ProjectCalculator {
|
||||
constructor() {
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
this.totalSteps = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Service selection
|
||||
document.querySelectorAll('.service-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleServiceSelection(e));
|
||||
});
|
||||
|
||||
// Complexity selection
|
||||
document.querySelectorAll('.complexity-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleComplexitySelection(e));
|
||||
});
|
||||
|
||||
// Timeline selection
|
||||
document.querySelectorAll('.timeline-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleTimelineSelection(e));
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.querySelectorAll('.next-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.nextStep());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.prevStep());
|
||||
});
|
||||
|
||||
// Restart button
|
||||
const restartBtn = document.querySelector('.restart-calculator');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => this.restart());
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
const service = option.dataset.service;
|
||||
const price = parseInt(option.dataset.basePrice);
|
||||
|
||||
option.classList.toggle('selected');
|
||||
|
||||
if (option.classList.contains('selected')) {
|
||||
this.selectedServices.push({ service, price });
|
||||
this.animateSelection(option, true);
|
||||
} else {
|
||||
this.selectedServices = this.selectedServices.filter(s => s.service !== service);
|
||||
this.animateSelection(option, false);
|
||||
}
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleComplexitySelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.complexity-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedComplexity = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleTimelineSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedTimeline = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
animateSelection(element, selected) {
|
||||
if (selected) {
|
||||
element.style.borderColor = '#3B82F6';
|
||||
element.style.backgroundColor = '#EBF8FF';
|
||||
element.style.transform = 'translateY(-2px)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.15)';
|
||||
} else {
|
||||
element.style.borderColor = '';
|
||||
element.style.backgroundColor = '';
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateStepButton() {
|
||||
const step1Button = document.querySelector('#step-1 .next-step');
|
||||
const step2Button = document.querySelector('#step-2 .next-step');
|
||||
|
||||
if (step1Button) {
|
||||
step1Button.disabled = this.selectedServices.length === 0;
|
||||
step1Button.style.opacity = this.selectedServices.length > 0 ? '1' : '0.6';
|
||||
}
|
||||
|
||||
if (step2Button) {
|
||||
const isValid = this.selectedComplexity && this.selectedTimeline;
|
||||
step2Button.disabled = !isValid;
|
||||
step2Button.style.opacity = isValid ? '1' : '0.6';
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progressBar = document.querySelector('.calculator-progress-bar');
|
||||
if (progressBar) {
|
||||
const progress = (this.currentStep / this.totalSteps) * 100;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.className = `calculator-progress-bar step-${this.currentStep}`;
|
||||
}
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep >= this.totalSteps) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const nextStepElement = currentStepElement.nextElementSibling;
|
||||
|
||||
if (nextStepElement && nextStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in next step
|
||||
nextStepElement.classList.add('active');
|
||||
nextStepElement.style.display = 'block';
|
||||
nextStepElement.style.opacity = '0';
|
||||
nextStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
nextStepElement.style.opacity = '1';
|
||||
nextStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep++;
|
||||
this.updateProgressBar();
|
||||
|
||||
if (this.currentStep === 3) {
|
||||
setTimeout(() => this.calculateFinalPrice(), 300);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep <= 1) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const prevStepElement = currentStepElement.previousElementSibling;
|
||||
|
||||
if (prevStepElement && prevStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in previous step
|
||||
prevStepElement.classList.add('active');
|
||||
prevStepElement.style.display = 'block';
|
||||
prevStepElement.style.opacity = '0';
|
||||
prevStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
prevStepElement.style.opacity = '1';
|
||||
prevStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep--;
|
||||
this.updateProgressBar();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
calculateFinalPrice() {
|
||||
let total = 0;
|
||||
|
||||
// Calculate base price from services
|
||||
this.selectedServices.forEach(service => {
|
||||
total += service.price;
|
||||
});
|
||||
|
||||
// Apply complexity multiplier
|
||||
if (this.selectedComplexity) {
|
||||
total *= this.selectedComplexity.multiplier;
|
||||
}
|
||||
|
||||
// Apply timeline multiplier
|
||||
if (this.selectedTimeline) {
|
||||
total *= this.selectedTimeline.multiplier;
|
||||
}
|
||||
|
||||
// Animate price reveal
|
||||
const priceElement = document.getElementById('final-price');
|
||||
if (priceElement) {
|
||||
priceElement.style.opacity = '0';
|
||||
priceElement.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
priceElement.textContent = '₩' + total.toLocaleString();
|
||||
priceElement.style.opacity = '1';
|
||||
priceElement.style.transform = 'scale(1)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
setTimeout(() => this.generateSummary(total), 600);
|
||||
}
|
||||
|
||||
generateSummary(total) {
|
||||
const summary = document.getElementById('project-summary');
|
||||
if (!summary) return;
|
||||
|
||||
let summaryHTML = '';
|
||||
|
||||
// Services
|
||||
const servicesLabel = window.calculatorTranslations?.result?.selected_services || 'Selected Services';
|
||||
const complexityLabel = window.calculatorTranslations?.result?.complexity || 'Complexity';
|
||||
const timelineLabel = window.calculatorTranslations?.result?.timeline || 'Timeline';
|
||||
|
||||
summaryHTML += `<div class="mb-4"><strong>${servicesLabel}:</strong></div>`;
|
||||
this.selectedServices.forEach(service => {
|
||||
summaryHTML += `<div class="ml-4 mb-2 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
${this.getServiceName(service.service)}
|
||||
<span class="ml-auto font-semibold">₩${service.price.toLocaleString()}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Complexity
|
||||
if (this.selectedComplexity) {
|
||||
summaryHTML += `<div class="mt-4 mb-2 flex items-center">
|
||||
<i class="fas fa-layer-group text-blue-500 mr-2"></i>
|
||||
<strong>${complexityLabel}:</strong>
|
||||
<span class="ml-2">${this.getComplexityName(this.selectedComplexity.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedComplexity.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (this.selectedTimeline) {
|
||||
summaryHTML += `<div class="mb-2 flex items-center">
|
||||
<i class="fas fa-clock text-purple-500 mr-2"></i>
|
||||
<strong>${timelineLabel}:</strong>
|
||||
<span class="ml-2">${this.getTimelineName(this.selectedTimeline.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedTimeline.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Animate summary appearance
|
||||
summary.style.opacity = '0';
|
||||
summary.innerHTML = summaryHTML;
|
||||
|
||||
setTimeout(() => {
|
||||
summary.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getServiceName(service) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.services) {
|
||||
return window.calculatorTranslations.services[service] || service;
|
||||
}
|
||||
|
||||
const names = {
|
||||
web: 'Web Development',
|
||||
mobile: 'Mobile App',
|
||||
design: 'UI/UX Design',
|
||||
marketing: 'Digital Marketing'
|
||||
};
|
||||
return names[service] || service;
|
||||
}
|
||||
|
||||
getComplexityName(complexity) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.complexity) {
|
||||
return window.calculatorTranslations.complexity[complexity] || complexity;
|
||||
}
|
||||
|
||||
const names = {
|
||||
simple: 'Simple',
|
||||
medium: 'Medium',
|
||||
complex: 'Complex'
|
||||
};
|
||||
return names[complexity] || complexity;
|
||||
}
|
||||
|
||||
getTimelineName(timeline) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.timeline) {
|
||||
return window.calculatorTranslations.timeline[timeline] || timeline;
|
||||
}
|
||||
|
||||
const names = {
|
||||
standard: 'Standard',
|
||||
rush: 'Rush',
|
||||
extended: 'Extended'
|
||||
};
|
||||
return names[timeline] || timeline;
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Reset all selections
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.service-option, .complexity-option, .timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Reset steps
|
||||
document.querySelectorAll('.calculator-step').forEach(step => {
|
||||
step.classList.remove('active');
|
||||
step.style.display = 'none';
|
||||
step.style.opacity = '1';
|
||||
step.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
// Show first step
|
||||
const firstStep = document.getElementById('step-1');
|
||||
if (firstStep) {
|
||||
firstStep.classList.add('active');
|
||||
firstStep.style.display = 'block';
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
this.updateStepButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme initialization
|
||||
const theme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
||||
|
||||
// Initialize calculator
|
||||
if (document.querySelector('.calculator-step')) {
|
||||
new ProjectCalculator();
|
||||
}
|
||||
});
|
||||
@@ -1,384 +0,0 @@
|
||||
// Calculator Logic
|
||||
class ProjectCalculator {
|
||||
constructor() {
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
this.totalSteps = 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Service selection
|
||||
document.querySelectorAll('.service-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleServiceSelection(e));
|
||||
});
|
||||
|
||||
// Complexity selection
|
||||
document.querySelectorAll('.complexity-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleComplexitySelection(e));
|
||||
});
|
||||
|
||||
// Timeline selection
|
||||
document.querySelectorAll('.timeline-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => this.handleTimelineSelection(e));
|
||||
});
|
||||
|
||||
// Navigation buttons
|
||||
document.querySelectorAll('.next-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.nextStep());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.prevStep());
|
||||
});
|
||||
|
||||
// Restart button
|
||||
const restartBtn = document.querySelector('.restart-calculator');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => this.restart());
|
||||
}
|
||||
}
|
||||
|
||||
handleServiceSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
const service = option.dataset.service;
|
||||
const price = parseInt(option.dataset.basePrice);
|
||||
|
||||
option.classList.toggle('selected');
|
||||
|
||||
if (option.classList.contains('selected')) {
|
||||
this.selectedServices.push({ service, price });
|
||||
this.animateSelection(option, true);
|
||||
} else {
|
||||
this.selectedServices = this.selectedServices.filter(s => s.service !== service);
|
||||
this.animateSelection(option, false);
|
||||
}
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleComplexitySelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.complexity-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedComplexity = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
handleTimelineSelection(e) {
|
||||
const option = e.currentTarget;
|
||||
|
||||
// Clear previous selections
|
||||
document.querySelectorAll('.timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Select current option
|
||||
option.classList.add('selected');
|
||||
this.animateSelection(option, true);
|
||||
|
||||
this.selectedTimeline = {
|
||||
value: option.dataset.value,
|
||||
multiplier: parseFloat(option.dataset.multiplier)
|
||||
};
|
||||
|
||||
this.updateStepButton();
|
||||
}
|
||||
|
||||
animateSelection(element, selected) {
|
||||
if (selected) {
|
||||
element.style.borderColor = '#3B82F6';
|
||||
element.style.backgroundColor = '#EBF8FF';
|
||||
element.style.transform = 'translateY(-2px)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(59, 130, 246, 0.15)';
|
||||
} else {
|
||||
element.style.borderColor = '';
|
||||
element.style.backgroundColor = '';
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateStepButton() {
|
||||
const step1Button = document.querySelector('#step-1 .next-step');
|
||||
const step2Button = document.querySelector('#step-2 .next-step');
|
||||
|
||||
if (step1Button) {
|
||||
step1Button.disabled = this.selectedServices.length === 0;
|
||||
step1Button.style.opacity = this.selectedServices.length > 0 ? '1' : '0.6';
|
||||
}
|
||||
|
||||
if (step2Button) {
|
||||
const isValid = this.selectedComplexity && this.selectedTimeline;
|
||||
step2Button.disabled = !isValid;
|
||||
step2Button.style.opacity = isValid ? '1' : '0.6';
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progressBar = document.querySelector('.calculator-progress-bar');
|
||||
if (progressBar) {
|
||||
const progress = (this.currentStep / this.totalSteps) * 100;
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.className = `calculator-progress-bar step-${this.currentStep}`;
|
||||
}
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep >= this.totalSteps) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const nextStepElement = currentStepElement.nextElementSibling;
|
||||
|
||||
if (nextStepElement && nextStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in next step
|
||||
nextStepElement.classList.add('active');
|
||||
nextStepElement.style.display = 'block';
|
||||
nextStepElement.style.opacity = '0';
|
||||
nextStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
nextStepElement.style.opacity = '1';
|
||||
nextStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep++;
|
||||
this.updateProgressBar();
|
||||
|
||||
if (this.currentStep === 3) {
|
||||
setTimeout(() => this.calculateFinalPrice(), 300);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep <= 1) return;
|
||||
|
||||
const currentStepElement = document.querySelector('.calculator-step.active');
|
||||
const prevStepElement = currentStepElement.previousElementSibling;
|
||||
|
||||
if (prevStepElement && prevStepElement.classList.contains('calculator-step')) {
|
||||
// Animate out current step
|
||||
currentStepElement.style.opacity = '0';
|
||||
currentStepElement.style.transform = 'translateX(20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
currentStepElement.classList.remove('active');
|
||||
currentStepElement.style.display = 'none';
|
||||
|
||||
// Animate in previous step
|
||||
prevStepElement.classList.add('active');
|
||||
prevStepElement.style.display = 'block';
|
||||
prevStepElement.style.opacity = '0';
|
||||
prevStepElement.style.transform = 'translateX(-20px)';
|
||||
|
||||
setTimeout(() => {
|
||||
prevStepElement.style.opacity = '1';
|
||||
prevStepElement.style.transform = 'translateX(0)';
|
||||
}, 50);
|
||||
|
||||
this.currentStep--;
|
||||
this.updateProgressBar();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
calculateFinalPrice() {
|
||||
let total = 0;
|
||||
|
||||
// Calculate base price from services
|
||||
this.selectedServices.forEach(service => {
|
||||
total += service.price;
|
||||
});
|
||||
|
||||
// Apply complexity multiplier
|
||||
if (this.selectedComplexity) {
|
||||
total *= this.selectedComplexity.multiplier;
|
||||
}
|
||||
|
||||
// Apply timeline multiplier
|
||||
if (this.selectedTimeline) {
|
||||
total *= this.selectedTimeline.multiplier;
|
||||
}
|
||||
|
||||
// Animate price reveal
|
||||
const priceElement = document.getElementById('final-price');
|
||||
if (priceElement) {
|
||||
priceElement.style.opacity = '0';
|
||||
priceElement.style.transform = 'scale(0.8)';
|
||||
|
||||
setTimeout(() => {
|
||||
priceElement.textContent = '₩' + total.toLocaleString();
|
||||
priceElement.style.opacity = '1';
|
||||
priceElement.style.transform = 'scale(1)';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
setTimeout(() => this.generateSummary(total), 600);
|
||||
}
|
||||
|
||||
generateSummary(total) {
|
||||
const summary = document.getElementById('project-summary');
|
||||
if (!summary) return;
|
||||
|
||||
let summaryHTML = '';
|
||||
|
||||
// Services
|
||||
const servicesLabel = window.calculatorTranslations?.result?.selected_services || 'Selected Services';
|
||||
const complexityLabel = window.calculatorTranslations?.result?.complexity || 'Complexity';
|
||||
const timelineLabel = window.calculatorTranslations?.result?.timeline || 'Timeline';
|
||||
|
||||
summaryHTML += `<div class="mb-4"><strong>${servicesLabel}:</strong></div>`;
|
||||
this.selectedServices.forEach(service => {
|
||||
summaryHTML += `<div class="ml-4 mb-2 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
${this.getServiceName(service.service)}
|
||||
<span class="ml-auto font-semibold">₩${service.price.toLocaleString()}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Complexity
|
||||
if (this.selectedComplexity) {
|
||||
summaryHTML += `<div class="mt-4 mb-2 flex items-center">
|
||||
<i class="fas fa-layer-group text-blue-500 mr-2"></i>
|
||||
<strong>${complexityLabel}:</strong>
|
||||
<span class="ml-2">${this.getComplexityName(this.selectedComplexity.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedComplexity.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Timeline
|
||||
if (this.selectedTimeline) {
|
||||
summaryHTML += `<div class="mb-2 flex items-center">
|
||||
<i class="fas fa-clock text-purple-500 mr-2"></i>
|
||||
<strong>${timelineLabel}:</strong>
|
||||
<span class="ml-2">${this.getTimelineName(this.selectedTimeline.value)}</span>
|
||||
<span class="ml-auto text-sm text-gray-500">×${this.selectedTimeline.multiplier}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Animate summary appearance
|
||||
summary.style.opacity = '0';
|
||||
summary.innerHTML = summaryHTML;
|
||||
|
||||
setTimeout(() => {
|
||||
summary.style.opacity = '1';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getServiceName(service) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.services) {
|
||||
return window.calculatorTranslations.services[service] || service;
|
||||
}
|
||||
|
||||
const names = {
|
||||
web: 'Web Development',
|
||||
mobile: 'Mobile App',
|
||||
design: 'UI/UX Design',
|
||||
marketing: 'Digital Marketing'
|
||||
};
|
||||
return names[service] || service;
|
||||
}
|
||||
|
||||
getComplexityName(complexity) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.complexity) {
|
||||
return window.calculatorTranslations.complexity[complexity] || complexity;
|
||||
}
|
||||
|
||||
const names = {
|
||||
simple: 'Simple',
|
||||
medium: 'Medium',
|
||||
complex: 'Complex'
|
||||
};
|
||||
return names[complexity] || complexity;
|
||||
}
|
||||
|
||||
getTimelineName(timeline) {
|
||||
if (window.calculatorTranslations && window.calculatorTranslations.timeline) {
|
||||
return window.calculatorTranslations.timeline[timeline] || timeline;
|
||||
}
|
||||
|
||||
const names = {
|
||||
standard: 'Standard',
|
||||
rush: 'Rush',
|
||||
extended: 'Extended'
|
||||
};
|
||||
return names[timeline] || timeline;
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Reset all selections
|
||||
this.selectedServices = [];
|
||||
this.selectedComplexity = null;
|
||||
this.selectedTimeline = null;
|
||||
this.currentStep = 1;
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.service-option, .complexity-option, .timeline-option').forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
this.animateSelection(opt, false);
|
||||
});
|
||||
|
||||
// Reset steps
|
||||
document.querySelectorAll('.calculator-step').forEach(step => {
|
||||
step.classList.remove('active');
|
||||
step.style.display = 'none';
|
||||
step.style.opacity = '1';
|
||||
step.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
// Show first step
|
||||
const firstStep = document.getElementById('step-1');
|
||||
if (firstStep) {
|
||||
firstStep.classList.add('active');
|
||||
firstStep.style.display = 'block';
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
this.updateStepButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calculator when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Theme initialization
|
||||
const theme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
||||
|
||||
// Initialize calculator
|
||||
if (document.querySelector('.calculator-step')) {
|
||||
new ProjectCalculator();
|
||||
}
|
||||
});
|
||||
52
.history/public/js/debug-calculator_20251026101421.js
Normal file
52
.history/public/js/debug-calculator_20251026101421.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Отладочная версия калькулятора
|
||||
console.log('Debug calculator loaded');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('DOM loaded');
|
||||
|
||||
// Проверим наличие элементов
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
console.log('priceDisplay:', priceDisplay);
|
||||
console.log('mobilePriceDisplay:', mobilePriceDisplay);
|
||||
|
||||
if (priceDisplay) {
|
||||
console.log('priceDisplay classes:', priceDisplay.className);
|
||||
|
||||
// Тестируем показ блока
|
||||
setTimeout(() => {
|
||||
console.log('Showing price display...');
|
||||
priceDisplay.classList.remove('hidden');
|
||||
|
||||
// Обновляем цену
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
if (currentPrice) {
|
||||
currentPrice.textContent = '₩500,000';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Добавим обработчики для карточек сервисов
|
||||
document.querySelectorAll('.service-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
console.log('Service card clicked:', card.dataset.service);
|
||||
|
||||
if (priceDisplay) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
if (currentPrice) {
|
||||
currentPrice.textContent = '₩750,000';
|
||||
}
|
||||
}
|
||||
|
||||
if (mobilePriceDisplay) {
|
||||
mobilePriceDisplay.classList.remove('hidden');
|
||||
const mobilePriceValue = document.getElementById('mobilePriceValue');
|
||||
if (mobilePriceValue) {
|
||||
mobilePriceValue.textContent = '₩750,000';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
52
.history/public/js/debug-calculator_20251026101507.js
Normal file
52
.history/public/js/debug-calculator_20251026101507.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Отладочная версия калькулятора
|
||||
console.log('Debug calculator loaded');
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('DOM loaded');
|
||||
|
||||
// Проверим наличие элементов
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const mobilePriceDisplay = document.getElementById('mobilePriceDisplay');
|
||||
|
||||
console.log('priceDisplay:', priceDisplay);
|
||||
console.log('mobilePriceDisplay:', mobilePriceDisplay);
|
||||
|
||||
if (priceDisplay) {
|
||||
console.log('priceDisplay classes:', priceDisplay.className);
|
||||
|
||||
// Тестируем показ блока
|
||||
setTimeout(() => {
|
||||
console.log('Showing price display...');
|
||||
priceDisplay.classList.remove('hidden');
|
||||
|
||||
// Обновляем цену
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
if (currentPrice) {
|
||||
currentPrice.textContent = '₩500,000';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Добавим обработчики для карточек сервисов
|
||||
document.querySelectorAll('.service-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
console.log('Service card clicked:', card.dataset.service);
|
||||
|
||||
if (priceDisplay) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
if (currentPrice) {
|
||||
currentPrice.textContent = '₩750,000';
|
||||
}
|
||||
}
|
||||
|
||||
if (mobilePriceDisplay) {
|
||||
mobilePriceDisplay.classList.remove('hidden');
|
||||
const mobilePriceValue = document.getElementById('mobilePriceValue');
|
||||
if (mobilePriceValue) {
|
||||
mobilePriceValue.textContent = '₩750,000';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,68 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle
|
||||
function applyTheme(isDark) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(24px)';
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
if (sunIcon) sunIcon.classList.add('hidden');
|
||||
if (moonIcon) moonIcon.classList.remove('hidden');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
if (sunIcon) sunIcon.classList.remove('hidden');
|
||||
if (moonIcon) moonIcon.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application
|
||||
applyTheme(currentTheme === 'dark');
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
@@ -10,6 +10,68 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle
|
||||
function applyTheme(isDark) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(24px)';
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
if (sunIcon) sunIcon.classList.add('hidden');
|
||||
if (moonIcon) moonIcon.classList.remove('hidden');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
if (sunIcon) sunIcon.classList.remove('hidden');
|
||||
if (moonIcon) moonIcon.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application
|
||||
applyTheme(currentTheme === 'dark');
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
638
.history/public/js/main_20251026092237.js
Normal file
638
.history/public/js/main_20251026092237.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = true) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
if (animate) {
|
||||
// Smooth slide to right
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Animated transition: sun rotates out, moon rotates in
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
if (animate) {
|
||||
// Smooth slide to left
|
||||
slider.style.transform = 'translateX(0)';
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Animated transition: moon rotates out, sun rotates in
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application
|
||||
applyTheme(currentTheme === 'dark');
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
638
.history/public/js/main_20251026092245.js
Normal file
638
.history/public/js/main_20251026092245.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = true) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
if (animate) {
|
||||
// Smooth slide to right
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Animated transition: sun rotates out, moon rotates in
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
if (animate) {
|
||||
// Smooth slide to left
|
||||
slider.style.transform = 'translateX(0)';
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Animated transition: moon rotates out, sun rotates in
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
662
.history/public/js/main_20251026092339.js
Normal file
662
.history/public/js/main_20251026092339.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = true) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
if (animate) {
|
||||
// Smooth background color transition
|
||||
slider.style.backgroundColor = '#374151';
|
||||
} else {
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Remove any existing animation classes
|
||||
sunIcon.classList.remove('icon-animate-in');
|
||||
moonIcon.classList.remove('icon-animate-in');
|
||||
|
||||
// Add animation classes
|
||||
sunIcon.classList.add('icon-animate-out');
|
||||
moonIcon.classList.add('icon-animate-in');
|
||||
|
||||
// Set final states
|
||||
setTimeout(() => {
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
if (animate) {
|
||||
// Smooth background color transition
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
} else {
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Remove any existing animation classes
|
||||
sunIcon.classList.remove('icon-animate-out');
|
||||
moonIcon.classList.remove('icon-animate-out');
|
||||
|
||||
// Add animation classes
|
||||
moonIcon.classList.add('icon-animate-out');
|
||||
sunIcon.classList.add('icon-animate-in');
|
||||
|
||||
// Set final states
|
||||
setTimeout(() => {
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
662
.history/public/js/main_20251026092353.js
Normal file
662
.history/public/js/main_20251026092353.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const currentTheme = localTheme || serverTheme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = true) {
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
if (animate) {
|
||||
// Smooth background color transition
|
||||
slider.style.backgroundColor = '#374151';
|
||||
} else {
|
||||
slider.style.backgroundColor = '#374151';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Remove any existing animation classes
|
||||
sunIcon.classList.remove('icon-animate-in');
|
||||
moonIcon.classList.remove('icon-animate-in');
|
||||
|
||||
// Add animation classes
|
||||
sunIcon.classList.add('icon-animate-out');
|
||||
moonIcon.classList.add('icon-animate-in');
|
||||
|
||||
// Set final states
|
||||
setTimeout(() => {
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
if (animate) {
|
||||
// Smooth background color transition
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
} else {
|
||||
slider.style.backgroundColor = '#ffffff';
|
||||
}
|
||||
}
|
||||
if (sunIcon && moonIcon) {
|
||||
if (animate) {
|
||||
// Remove any existing animation classes
|
||||
sunIcon.classList.remove('icon-animate-out');
|
||||
moonIcon.classList.remove('icon-animate-out');
|
||||
|
||||
// Add animation classes
|
||||
moonIcon.classList.add('icon-animate-out');
|
||||
sunIcon.classList.add('icon-animate-in');
|
||||
|
||||
// Set final states
|
||||
setTimeout(() => {
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
639
.history/public/js/main_20251026092625.js
Normal file
639
.history/public/js/main_20251026092625.js
Normal file
@@ -0,0 +1,639 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(26px)'; // Adjusted for 6px slider + 1px border
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
639
.history/public/js/main_20251026092650.js
Normal file
639
.history/public/js/main_20251026092650.js
Normal file
@@ -0,0 +1,639 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(26px)'; // Adjusted for 6px slider + 1px border
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
640
.history/public/js/main_20251026092900.js
Normal file
640
.history/public/js/main_20251026092900.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
// Расчет: ширина контейнера (56px) - ширина ползунка (20px) - отступы границ (4px) = 32px
|
||||
slider.style.transform = 'translateX(32px)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
640
.history/public/js/main_20251026092941.js
Normal file
640
.history/public/js/main_20251026092941.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
// Расчет: ширина контейнера (56px) - ширина ползунка (20px) - отступы границ (4px) = 32px
|
||||
slider.style.transform = 'translateX(32px)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
640
.history/public/js/main_20251026093130.js
Normal file
640
.history/public/js/main_20251026093130.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
// Расчет: ширина контейнера (56px) - ширина ползунка (20px) - отступы (8px) = 28px движения
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
640
.history/public/js/main_20251026093149.js
Normal file
640
.history/public/js/main_20251026093149.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// Main JavaScript for SmartSolTech Website
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize AOS (Animate On Scroll)
|
||||
if (typeof AOS !== 'undefined') {
|
||||
AOS.init({
|
||||
duration: 800,
|
||||
easing: 'ease-in-out',
|
||||
once: true,
|
||||
offset: 100
|
||||
});
|
||||
}
|
||||
|
||||
// Theme Management
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
const slider = document.querySelector('.theme-toggle-slider');
|
||||
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||
|
||||
// Get current theme from server or localStorage
|
||||
const serverTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
const localTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: localStorage > server > system preference
|
||||
const currentTheme = localTheme || serverTheme || (prefersDark ? 'dark' : 'light');
|
||||
|
||||
console.log('Theme initialization:', { serverTheme, localTheme, prefersDark, currentTheme });
|
||||
|
||||
// Apply theme and update toggle with smooth animation
|
||||
function applyTheme(isDark, animate = false) {
|
||||
console.log('Applying theme:', isDark ? 'dark' : 'light', 'animate:', animate);
|
||||
|
||||
if (isDark) {
|
||||
html.classList.add('dark');
|
||||
if (themeToggle) themeToggle.checked = true;
|
||||
if (slider) {
|
||||
// Расчет: ширина контейнера (56px) - ширина ползунка (20px) - отступы (8px) = 28px движения
|
||||
slider.style.transform = 'translateX(28px)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to dark
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
sunIcon.style.transform = 'rotate(180deg) scale(0)';
|
||||
sunIcon.style.opacity = '0';
|
||||
moonIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
moonIcon.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
if (themeToggle) themeToggle.checked = false;
|
||||
if (slider) {
|
||||
slider.style.transform = 'translateX(0)';
|
||||
}
|
||||
if (sunIcon && moonIcon && animate) {
|
||||
// Animated transition to light
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
} else if (sunIcon && moonIcon) {
|
||||
// Instant set for initial load
|
||||
moonIcon.style.transform = 'rotate(-180deg) scale(0)';
|
||||
moonIcon.style.opacity = '0';
|
||||
sunIcon.style.transform = 'rotate(0deg) scale(1)';
|
||||
sunIcon.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial theme application (без анимации при загрузке)
|
||||
applyTheme(currentTheme === 'dark', false);
|
||||
|
||||
// Theme toggle handler
|
||||
function toggleTheme() {
|
||||
const isDark = html.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
console.log('Toggling theme from', isDark ? 'dark' : 'light', 'to', newTheme);
|
||||
|
||||
applyTheme(!isDark, true); // С анимацией при клике
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Send to server
|
||||
fetch(`/theme/${newTheme}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => console.log('Theme synced to server:', data))
|
||||
.catch(error => {
|
||||
console.warn('Theme sync failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('change', toggleTheme);
|
||||
console.log('Theme toggle listener attached');
|
||||
} else {
|
||||
console.warn('Theme toggle element not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
const mobileMenuButton = document.querySelector('.mobile-menu-button');
|
||||
const mobileMenu = document.querySelector('.mobile-menu');
|
||||
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('show');
|
||||
const isOpen = mobileMenu.classList.contains('show');
|
||||
|
||||
// Toggle button icon
|
||||
const icon = mobileMenuButton.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.innerHTML = isOpen
|
||||
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />'
|
||||
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />';
|
||||
}
|
||||
|
||||
// Accessibility
|
||||
mobileMenuButton.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!mobileMenuButton.contains(e.target) && !mobileMenu.contains(e.target)) {
|
||||
mobileMenu.classList.remove('show');
|
||||
mobileMenuButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('nav');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
if (navbar) {
|
||||
if (currentScrollY > 100) {
|
||||
navbar.classList.add('navbar-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('navbar-scrolled');
|
||||
}
|
||||
|
||||
// Hide/show navbar on scroll
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 200) {
|
||||
navbar.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
navbar.style.transform = 'translateY(0)';
|
||||
}
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
// Smooth Scrolling for Anchor Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
const offsetTop = target.offsetTop - (navbar ? navbar.offsetHeight : 0);
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Contact Form Handler
|
||||
const quickContactForm = document.getElementById('quick-contact-form');
|
||||
if (quickContactForm) {
|
||||
quickContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
const mainContactForm = document.getElementById('contact-form');
|
||||
if (mainContactForm) {
|
||||
mainContactForm.addEventListener('submit', handleContactSubmit);
|
||||
}
|
||||
|
||||
async function handleContactSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
|
||||
// Show loading state
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('btn-loading');
|
||||
submitButton.textContent = '전송 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const response = await fetch('/api/contact/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('메시지가 성공적으로 전송되었습니다! 곧 연락드리겠습니다.', 'success');
|
||||
form.reset();
|
||||
} else {
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error);
|
||||
showNotification('메시지 전송 중 오류가 발생했습니다. 다시 시도해주세요.', 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('btn-loading');
|
||||
submitButton.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Notification System
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" aria-label="Close notification">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Close button handler
|
||||
const closeButton = notification.querySelector('.notification-close');
|
||||
closeButton.addEventListener('click', () => {
|
||||
closeNotification(notification);
|
||||
});
|
||||
|
||||
// Auto close after 5 seconds
|
||||
setTimeout(() => {
|
||||
closeNotification(notification);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function closeNotification(notification) {
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Portfolio Filter (if on portfolio page)
|
||||
const portfolioFilters = document.querySelectorAll('.portfolio-filter');
|
||||
const portfolioItems = document.querySelectorAll('.portfolio-item');
|
||||
|
||||
portfolioFilters.forEach(filter => {
|
||||
filter.addEventListener('click', function() {
|
||||
const category = this.dataset.category;
|
||||
|
||||
// Update active filter
|
||||
portfolioFilters.forEach(f => f.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
portfolioItems.forEach(item => {
|
||||
if (category === 'all' || item.dataset.category === category) {
|
||||
item.style.display = 'block';
|
||||
item.style.animation = 'fadeIn 0.5s ease';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Image Lazy Loading
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
|
||||
// Service Worker Registration for PWA
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered successfully:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Monitoring
|
||||
if ('performance' in window) {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
const perfData = performance.getEntriesByType('navigation')[0];
|
||||
const loadTime = perfData.loadEventEnd - perfData.loadEventStart;
|
||||
|
||||
if (loadTime > 3000) {
|
||||
console.warn('Page load time is slow:', loadTime + 'ms');
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Consent (if needed)
|
||||
function initCookieConsent() {
|
||||
const consent = localStorage.getItem('cookieConsent');
|
||||
if (!consent) {
|
||||
showCookieConsent();
|
||||
}
|
||||
}
|
||||
|
||||
function showCookieConsent() {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'cookie-consent';
|
||||
banner.innerHTML = `
|
||||
<div class="cookie-content">
|
||||
<p>이 웹사이트는 더 나은 서비스 제공을 위해 쿠키를 사용합니다.</p>
|
||||
<div class="cookie-buttons">
|
||||
<button id="accept-cookies" class="btn btn-primary">동의</button>
|
||||
<button id="decline-cookies" class="btn btn-secondary">거부</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
banner.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
setTimeout(() => {
|
||||
banner.style.transform = 'translateY(0)';
|
||||
}, 100);
|
||||
|
||||
document.getElementById('accept-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'accepted');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
|
||||
document.getElementById('decline-cookies').addEventListener('click', () => {
|
||||
localStorage.setItem('cookieConsent', 'declined');
|
||||
banner.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => banner.remove(), 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize cookie consent
|
||||
// initCookieConsent();
|
||||
|
||||
// Parallax Effect
|
||||
const parallaxElements = document.querySelectorAll('.parallax');
|
||||
|
||||
function updateParallax() {
|
||||
const scrollY = window.pageYOffset;
|
||||
|
||||
parallaxElements.forEach(element => {
|
||||
const speed = element.dataset.speed || 0.5;
|
||||
const yPos = -(scrollY * speed);
|
||||
element.style.transform = `translateY(${yPos}px)`;
|
||||
});
|
||||
}
|
||||
|
||||
if (parallaxElements.length > 0) {
|
||||
window.addEventListener('scroll', updateParallax);
|
||||
}
|
||||
|
||||
// Counter Animation
|
||||
const counters = document.querySelectorAll('.counter');
|
||||
const counterObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCounter(entry.target);
|
||||
counterObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
counters.forEach(counter => counterObserver.observe(counter));
|
||||
|
||||
function animateCounter(element) {
|
||||
const target = parseInt(element.dataset.count);
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateCounter(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const current = Math.floor(progress * target);
|
||||
element.textContent = current.toLocaleString();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
|
||||
// Typing Effect for Hero Text
|
||||
const typingElements = document.querySelectorAll('.typing-effect');
|
||||
|
||||
typingElements.forEach(element => {
|
||||
const text = element.textContent;
|
||||
element.textContent = '';
|
||||
element.style.borderRight = '2px solid';
|
||||
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
element.textContent += text[i];
|
||||
i++;
|
||||
|
||||
if (i >= text.length) {
|
||||
clearInterval(timer);
|
||||
element.style.borderRight = 'none';
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle form validation
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(this)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time validation
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', () => validateField(input));
|
||||
input.addEventListener('input', () => clearFieldError(input));
|
||||
});
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const type = field.type;
|
||||
let isValid = true;
|
||||
let message = '';
|
||||
|
||||
// Required field check
|
||||
if (field.hasAttribute('required') && !value) {
|
||||
isValid = false;
|
||||
message = '이 필드는 필수입니다.';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (type === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 이메일 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (type === 'tel' && value) {
|
||||
const phoneRegex = /^[0-9-+\s()]+$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
isValid = false;
|
||||
message = '올바른 전화번호 형식을 입력해주세요.';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide error
|
||||
if (!isValid) {
|
||||
showFieldError(field, message);
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
field.classList.add('error');
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.cssText = 'color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;';
|
||||
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.parentNode.querySelector('.field-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
// Debounce function
|
||||
debounce: function(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle function
|
||||
throttle: function(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Format currency
|
||||
formatCurrency: function(amount, currency = 'KRW') {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
},
|
||||
|
||||
// Format date
|
||||
formatDate: function(date, options = {}) {
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return new Intl.DateTimeFormat('ko-KR', { ...defaultOptions, ...options }).format(new Date(date));
|
||||
}
|
||||
};
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(e) {
|
||||
console.error('Global error:', e.error);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
// Could send error to analytics service
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = utils;
|
||||
}
|
||||
289
.history/public/js/price-island_20251026100040.js
Normal file
289
.history/public/js/price-island_20251026100040.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Dynamic Price Island Controller
|
||||
class PriceIsland {
|
||||
constructor() {
|
||||
this.island = document.getElementById('priceIsland');
|
||||
this.container = document.getElementById('islandContainer');
|
||||
this.content = document.getElementById('islandContent');
|
||||
this.totalPrice = document.getElementById('totalPrice');
|
||||
|
||||
this.selectedServices = [];
|
||||
this.projectDetails = {};
|
||||
this.appliedPromo = null;
|
||||
this.isExpanded = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Listen for calculator changes
|
||||
document.addEventListener('calculatorStateChange', (e) => {
|
||||
this.updateIsland(e.detail);
|
||||
});
|
||||
|
||||
// Initialize promo code functionality
|
||||
this.initPromoCode();
|
||||
|
||||
// Show island initially as compact
|
||||
this.showCompact();
|
||||
}
|
||||
|
||||
updateIsland(calculatorData) {
|
||||
const { selectedServices, complexity, timeline, totalPrice } = calculatorData;
|
||||
|
||||
this.selectedServices = selectedServices || [];
|
||||
this.projectDetails = { complexity, timeline };
|
||||
|
||||
// Update total price
|
||||
this.updateTotalPrice(totalPrice || 0);
|
||||
|
||||
// Update content
|
||||
this.updateContent();
|
||||
|
||||
// Expand if there's data to show
|
||||
if (this.selectedServices.length > 0 || complexity || timeline) {
|
||||
this.expand();
|
||||
} else {
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
|
||||
updateTotalPrice(price) {
|
||||
if (this.totalPrice) {
|
||||
this.totalPrice.textContent = this.formatPrice(price);
|
||||
this.totalPrice.classList.add('price-update');
|
||||
setTimeout(() => {
|
||||
this.totalPrice.classList.remove('price-update');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
updateContent() {
|
||||
this.updateSelectedServices();
|
||||
this.updateProjectDetails();
|
||||
this.updatePriceBreakdown();
|
||||
}
|
||||
|
||||
updateSelectedServices() {
|
||||
const container = document.getElementById('selectedServices');
|
||||
const list = document.getElementById('servicesList');
|
||||
|
||||
if (this.selectedServices.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
list.innerHTML = this.selectedServices.map(service => `
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${service.name}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${this.formatPrice(service.price)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectDetails() {
|
||||
const container = document.getElementById('projectDetails');
|
||||
const list = document.getElementById('detailsList');
|
||||
|
||||
const details = [];
|
||||
if (this.projectDetails.complexity) {
|
||||
details.push(`Сложность: ${this.projectDetails.complexity.name} (×${this.projectDetails.complexity.multiplier})`);
|
||||
}
|
||||
if (this.projectDetails.timeline) {
|
||||
details.push(`Сроки: ${this.projectDetails.timeline.name} (×${this.projectDetails.timeline.multiplier})`);
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
list.innerHTML = details.map(detail => `
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${detail}</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updatePriceBreakdown() {
|
||||
const container = document.getElementById('priceBreakdown');
|
||||
const list = document.getElementById('breakdownList');
|
||||
|
||||
if (this.selectedServices.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
|
||||
const basePrice = this.selectedServices.reduce((sum, service) => sum + service.price, 0);
|
||||
const complexityMultiplier = this.projectDetails.complexity?.multiplier || 1;
|
||||
const timelineMultiplier = this.projectDetails.timeline?.multiplier || 1;
|
||||
|
||||
const afterComplexity = basePrice * complexityMultiplier;
|
||||
const final = afterComplexity * timelineMultiplier;
|
||||
|
||||
let breakdown = [
|
||||
{ label: 'Базовая стоимость', value: basePrice }
|
||||
];
|
||||
|
||||
if (complexityMultiplier !== 1) {
|
||||
breakdown.push({
|
||||
label: `Сложность (×${complexityMultiplier})`,
|
||||
value: afterComplexity
|
||||
});
|
||||
}
|
||||
|
||||
if (timelineMultiplier !== 1) {
|
||||
breakdown.push({
|
||||
label: `Сроки (×${timelineMultiplier})`,
|
||||
value: final
|
||||
});
|
||||
}
|
||||
|
||||
if (this.appliedPromo) {
|
||||
const discount = final * (this.appliedPromo.discount / 100);
|
||||
breakdown.push({
|
||||
label: `Скидка ${this.appliedPromo.code} (-${this.appliedPromo.discount}%)`,
|
||||
value: final - discount,
|
||||
isDiscount: true
|
||||
});
|
||||
}
|
||||
|
||||
list.innerHTML = breakdown.map(item => `
|
||||
<div class="flex justify-between items-center py-1 ${item.isDiscount ? 'text-green-600 dark:text-green-400' : ''}">
|
||||
<span class="text-sm">${item.label}</span>
|
||||
<span class="text-sm font-medium">${this.formatPrice(item.value)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
initPromoCode() {
|
||||
const promoBtn = document.getElementById('applyPromoBtn');
|
||||
const promoInput = document.getElementById('promoInput');
|
||||
const promoStatus = document.getElementById('promoStatus');
|
||||
|
||||
if (promoBtn && promoInput) {
|
||||
promoBtn.addEventListener('click', () => {
|
||||
const code = promoInput.value.trim().toUpperCase();
|
||||
this.applyPromoCode(code);
|
||||
});
|
||||
|
||||
promoInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const code = promoInput.value.trim().toUpperCase();
|
||||
this.applyPromoCode(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyPromoCode(code) {
|
||||
const promoStatus = document.getElementById('promoStatus');
|
||||
|
||||
// Simulate promo code validation
|
||||
const validCodes = {
|
||||
'WELCOME10': { discount: 10, name: 'Скидка новичка' },
|
||||
'SAVE20': { discount: 20, name: 'Экономия 20%' },
|
||||
'VIP15': { discount: 15, name: 'VIP скидка' }
|
||||
};
|
||||
|
||||
if (validCodes[code]) {
|
||||
this.appliedPromo = { code, ...validCodes[code] };
|
||||
promoStatus.textContent = `✓ Применен: ${this.appliedPromo.name}`;
|
||||
promoStatus.className = 'text-xs mt-1 text-green-600 dark:text-green-400';
|
||||
|
||||
// Trigger recalculation
|
||||
this.updateContent();
|
||||
this.recalculateTotal();
|
||||
} else if (code) {
|
||||
promoStatus.textContent = '✗ Неверный промокод';
|
||||
promoStatus.className = 'text-xs mt-1 text-red-600 dark:text-red-400';
|
||||
} else {
|
||||
promoStatus.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
recalculateTotal() {
|
||||
if (this.selectedServices.length > 0) {
|
||||
const basePrice = this.selectedServices.reduce((sum, service) => sum + service.price, 0);
|
||||
const complexityMultiplier = this.projectDetails.complexity?.multiplier || 1;
|
||||
const timelineMultiplier = this.projectDetails.timeline?.multiplier || 1;
|
||||
|
||||
let total = basePrice * complexityMultiplier * timelineMultiplier;
|
||||
|
||||
if (this.appliedPromo) {
|
||||
total = total * (1 - this.appliedPromo.discount / 100);
|
||||
}
|
||||
|
||||
this.updateTotalPrice(total);
|
||||
|
||||
// Also update mobile price if exists
|
||||
const mobilePrice = document.getElementById('mobilePriceValue');
|
||||
if (mobilePrice) {
|
||||
mobilePrice.textContent = this.formatPrice(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expand() {
|
||||
if (!this.isExpanded) {
|
||||
this.isExpanded = true;
|
||||
this.content.style.maxHeight = this.content.scrollHeight + 'px';
|
||||
|
||||
// Show promo section when expanded
|
||||
const promoSection = document.getElementById('promoSection');
|
||||
if (promoSection) {
|
||||
promoSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapse() {
|
||||
if (this.isExpanded) {
|
||||
this.isExpanded = false;
|
||||
this.content.style.maxHeight = '0';
|
||||
|
||||
// Hide promo section when collapsed
|
||||
const promoSection = document.getElementById('promoSection');
|
||||
if (promoSection) {
|
||||
promoSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showCompact() {
|
||||
this.island.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.island.classList.add('hidden');
|
||||
}
|
||||
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0
|
||||
}).format(price);
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for price update animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.price-update {
|
||||
animation: priceUpdate 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes priceUpdate {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.priceIsland = new PriceIsland();
|
||||
});
|
||||
|
||||
// Export for use in calculator
|
||||
window.PriceIsland = PriceIsland;
|
||||
289
.history/public/js/price-island_20251026100441.js
Normal file
289
.history/public/js/price-island_20251026100441.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Dynamic Price Island Controller
|
||||
class PriceIsland {
|
||||
constructor() {
|
||||
this.island = document.getElementById('priceIsland');
|
||||
this.container = document.getElementById('islandContainer');
|
||||
this.content = document.getElementById('islandContent');
|
||||
this.totalPrice = document.getElementById('totalPrice');
|
||||
|
||||
this.selectedServices = [];
|
||||
this.projectDetails = {};
|
||||
this.appliedPromo = null;
|
||||
this.isExpanded = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Listen for calculator changes
|
||||
document.addEventListener('calculatorStateChange', (e) => {
|
||||
this.updateIsland(e.detail);
|
||||
});
|
||||
|
||||
// Initialize promo code functionality
|
||||
this.initPromoCode();
|
||||
|
||||
// Show island initially as compact
|
||||
this.showCompact();
|
||||
}
|
||||
|
||||
updateIsland(calculatorData) {
|
||||
const { selectedServices, complexity, timeline, totalPrice } = calculatorData;
|
||||
|
||||
this.selectedServices = selectedServices || [];
|
||||
this.projectDetails = { complexity, timeline };
|
||||
|
||||
// Update total price
|
||||
this.updateTotalPrice(totalPrice || 0);
|
||||
|
||||
// Update content
|
||||
this.updateContent();
|
||||
|
||||
// Expand if there's data to show
|
||||
if (this.selectedServices.length > 0 || complexity || timeline) {
|
||||
this.expand();
|
||||
} else {
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
|
||||
updateTotalPrice(price) {
|
||||
if (this.totalPrice) {
|
||||
this.totalPrice.textContent = this.formatPrice(price);
|
||||
this.totalPrice.classList.add('price-update');
|
||||
setTimeout(() => {
|
||||
this.totalPrice.classList.remove('price-update');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
updateContent() {
|
||||
this.updateSelectedServices();
|
||||
this.updateProjectDetails();
|
||||
this.updatePriceBreakdown();
|
||||
}
|
||||
|
||||
updateSelectedServices() {
|
||||
const container = document.getElementById('selectedServices');
|
||||
const list = document.getElementById('servicesList');
|
||||
|
||||
if (this.selectedServices.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
list.innerHTML = this.selectedServices.map(service => `
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${service.name}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">${this.formatPrice(service.price)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectDetails() {
|
||||
const container = document.getElementById('projectDetails');
|
||||
const list = document.getElementById('detailsList');
|
||||
|
||||
const details = [];
|
||||
if (this.projectDetails.complexity) {
|
||||
details.push(`Сложность: ${this.projectDetails.complexity.name} (×${this.projectDetails.complexity.multiplier})`);
|
||||
}
|
||||
if (this.projectDetails.timeline) {
|
||||
details.push(`Сроки: ${this.projectDetails.timeline.name} (×${this.projectDetails.timeline.multiplier})`);
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
list.innerHTML = details.map(detail => `
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${detail}</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
updatePriceBreakdown() {
|
||||
const container = document.getElementById('priceBreakdown');
|
||||
const list = document.getElementById('breakdownList');
|
||||
|
||||
if (this.selectedServices.length > 0) {
|
||||
container.classList.remove('hidden');
|
||||
|
||||
const basePrice = this.selectedServices.reduce((sum, service) => sum + service.price, 0);
|
||||
const complexityMultiplier = this.projectDetails.complexity?.multiplier || 1;
|
||||
const timelineMultiplier = this.projectDetails.timeline?.multiplier || 1;
|
||||
|
||||
const afterComplexity = basePrice * complexityMultiplier;
|
||||
const final = afterComplexity * timelineMultiplier;
|
||||
|
||||
let breakdown = [
|
||||
{ label: 'Базовая стоимость', value: basePrice }
|
||||
];
|
||||
|
||||
if (complexityMultiplier !== 1) {
|
||||
breakdown.push({
|
||||
label: `Сложность (×${complexityMultiplier})`,
|
||||
value: afterComplexity
|
||||
});
|
||||
}
|
||||
|
||||
if (timelineMultiplier !== 1) {
|
||||
breakdown.push({
|
||||
label: `Сроки (×${timelineMultiplier})`,
|
||||
value: final
|
||||
});
|
||||
}
|
||||
|
||||
if (this.appliedPromo) {
|
||||
const discount = final * (this.appliedPromo.discount / 100);
|
||||
breakdown.push({
|
||||
label: `Скидка ${this.appliedPromo.code} (-${this.appliedPromo.discount}%)`,
|
||||
value: final - discount,
|
||||
isDiscount: true
|
||||
});
|
||||
}
|
||||
|
||||
list.innerHTML = breakdown.map(item => `
|
||||
<div class="flex justify-between items-center py-1 ${item.isDiscount ? 'text-green-600 dark:text-green-400' : ''}">
|
||||
<span class="text-sm">${item.label}</span>
|
||||
<span class="text-sm font-medium">${this.formatPrice(item.value)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
initPromoCode() {
|
||||
const promoBtn = document.getElementById('applyPromoBtn');
|
||||
const promoInput = document.getElementById('promoInput');
|
||||
const promoStatus = document.getElementById('promoStatus');
|
||||
|
||||
if (promoBtn && promoInput) {
|
||||
promoBtn.addEventListener('click', () => {
|
||||
const code = promoInput.value.trim().toUpperCase();
|
||||
this.applyPromoCode(code);
|
||||
});
|
||||
|
||||
promoInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const code = promoInput.value.trim().toUpperCase();
|
||||
this.applyPromoCode(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyPromoCode(code) {
|
||||
const promoStatus = document.getElementById('promoStatus');
|
||||
|
||||
// Simulate promo code validation
|
||||
const validCodes = {
|
||||
'WELCOME10': { discount: 10, name: 'Скидка новичка' },
|
||||
'SAVE20': { discount: 20, name: 'Экономия 20%' },
|
||||
'VIP15': { discount: 15, name: 'VIP скидка' }
|
||||
};
|
||||
|
||||
if (validCodes[code]) {
|
||||
this.appliedPromo = { code, ...validCodes[code] };
|
||||
promoStatus.textContent = `✓ Применен: ${this.appliedPromo.name}`;
|
||||
promoStatus.className = 'text-xs mt-1 text-green-600 dark:text-green-400';
|
||||
|
||||
// Trigger recalculation
|
||||
this.updateContent();
|
||||
this.recalculateTotal();
|
||||
} else if (code) {
|
||||
promoStatus.textContent = '✗ Неверный промокод';
|
||||
promoStatus.className = 'text-xs mt-1 text-red-600 dark:text-red-400';
|
||||
} else {
|
||||
promoStatus.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
recalculateTotal() {
|
||||
if (this.selectedServices.length > 0) {
|
||||
const basePrice = this.selectedServices.reduce((sum, service) => sum + service.price, 0);
|
||||
const complexityMultiplier = this.projectDetails.complexity?.multiplier || 1;
|
||||
const timelineMultiplier = this.projectDetails.timeline?.multiplier || 1;
|
||||
|
||||
let total = basePrice * complexityMultiplier * timelineMultiplier;
|
||||
|
||||
if (this.appliedPromo) {
|
||||
total = total * (1 - this.appliedPromo.discount / 100);
|
||||
}
|
||||
|
||||
this.updateTotalPrice(total);
|
||||
|
||||
// Also update mobile price if exists
|
||||
const mobilePrice = document.getElementById('mobilePriceValue');
|
||||
if (mobilePrice) {
|
||||
mobilePrice.textContent = this.formatPrice(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expand() {
|
||||
if (!this.isExpanded) {
|
||||
this.isExpanded = true;
|
||||
this.content.style.maxHeight = this.content.scrollHeight + 'px';
|
||||
|
||||
// Show promo section when expanded
|
||||
const promoSection = document.getElementById('promoSection');
|
||||
if (promoSection) {
|
||||
promoSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapse() {
|
||||
if (this.isExpanded) {
|
||||
this.isExpanded = false;
|
||||
this.content.style.maxHeight = '0';
|
||||
|
||||
// Hide promo section when collapsed
|
||||
const promoSection = document.getElementById('promoSection');
|
||||
if (promoSection) {
|
||||
promoSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showCompact() {
|
||||
this.island.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.island.classList.add('hidden');
|
||||
}
|
||||
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0
|
||||
}).format(price);
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for price update animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.price-update {
|
||||
animation: priceUpdate 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes priceUpdate {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.priceIsland = new PriceIsland();
|
||||
});
|
||||
|
||||
// Export for use in calculator
|
||||
window.PriceIsland = PriceIsland;
|
||||
107
.history/public/js/sticky-price_20251026093525.js
Normal file
107
.history/public/js/sticky-price_20251026093525.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// Enhanced Sticky Price Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
|
||||
if (!priceDisplay) return;
|
||||
|
||||
// Show price display when calculator starts
|
||||
function showPriceDisplay() {
|
||||
if (priceDisplay.classList.contains('hidden')) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
priceDisplay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced scroll behavior
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Show/hide based on scroll position
|
||||
if (scrollY > 100) {
|
||||
showPriceDisplay();
|
||||
priceDisplay.classList.add('scrolled');
|
||||
} else {
|
||||
priceDisplay.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
// Add subtle bounce effect
|
||||
scrollTimeout = setTimeout(() => {
|
||||
priceDisplay.style.transform = 'translateX(0) scale(1)';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Price update animation
|
||||
const originalUpdatePrice = window.updatePrice || function() {};
|
||||
window.updatePrice = function(price, animate = true) {
|
||||
if (currentPrice && animate) {
|
||||
// Add updating animation
|
||||
currentPrice.classList.add('updating');
|
||||
|
||||
// Update price after brief delay
|
||||
setTimeout(() => {
|
||||
currentPrice.textContent = price;
|
||||
currentPrice.classList.remove('updating');
|
||||
}, 150);
|
||||
} else if (currentPrice) {
|
||||
currentPrice.textContent = price;
|
||||
}
|
||||
|
||||
// Show price display when price is calculated
|
||||
showPriceDisplay();
|
||||
|
||||
// Call original function if it exists
|
||||
if (typeof originalUpdatePrice === 'function') {
|
||||
originalUpdatePrice(price, animate);
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor calculator interactions
|
||||
const calculator = document.querySelector('[data-calculator]') || document.querySelector('.calculator-container');
|
||||
if (calculator) {
|
||||
// Show price display when user interacts with calculator
|
||||
const inputs = calculator.querySelectorAll('input, select, button');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('change', showPriceDisplay);
|
||||
input.addEventListener('click', showPriceDisplay);
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced visibility on mobile
|
||||
function handleMobileVisibility() {
|
||||
if (window.innerWidth <= 1024) {
|
||||
priceDisplay.classList.remove('fixed');
|
||||
priceDisplay.classList.add('relative');
|
||||
} else {
|
||||
priceDisplay.classList.remove('relative');
|
||||
priceDisplay.classList.add('fixed');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check and resize listener
|
||||
handleMobileVisibility();
|
||||
window.addEventListener('resize', handleMobileVisibility);
|
||||
|
||||
// Smooth reveal animation
|
||||
setTimeout(() => {
|
||||
if (priceDisplay && !priceDisplay.classList.contains('hidden')) {
|
||||
priceDisplay.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Auto-show after initial page load
|
||||
setTimeout(showPriceDisplay, 1000);
|
||||
});
|
||||
|
||||
// Helper function for other scripts to trigger price display
|
||||
window.showStickyPrice = function() {
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
if (priceDisplay) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
priceDisplay.classList.add('visible');
|
||||
}
|
||||
};
|
||||
107
.history/public/js/sticky-price_20251026093548.js
Normal file
107
.history/public/js/sticky-price_20251026093548.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// Enhanced Sticky Price Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
const currentPrice = document.getElementById('currentPrice');
|
||||
|
||||
if (!priceDisplay) return;
|
||||
|
||||
// Show price display when calculator starts
|
||||
function showPriceDisplay() {
|
||||
if (priceDisplay.classList.contains('hidden')) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
priceDisplay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced scroll behavior
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Show/hide based on scroll position
|
||||
if (scrollY > 100) {
|
||||
showPriceDisplay();
|
||||
priceDisplay.classList.add('scrolled');
|
||||
} else {
|
||||
priceDisplay.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
// Add subtle bounce effect
|
||||
scrollTimeout = setTimeout(() => {
|
||||
priceDisplay.style.transform = 'translateX(0) scale(1)';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Price update animation
|
||||
const originalUpdatePrice = window.updatePrice || function() {};
|
||||
window.updatePrice = function(price, animate = true) {
|
||||
if (currentPrice && animate) {
|
||||
// Add updating animation
|
||||
currentPrice.classList.add('updating');
|
||||
|
||||
// Update price after brief delay
|
||||
setTimeout(() => {
|
||||
currentPrice.textContent = price;
|
||||
currentPrice.classList.remove('updating');
|
||||
}, 150);
|
||||
} else if (currentPrice) {
|
||||
currentPrice.textContent = price;
|
||||
}
|
||||
|
||||
// Show price display when price is calculated
|
||||
showPriceDisplay();
|
||||
|
||||
// Call original function if it exists
|
||||
if (typeof originalUpdatePrice === 'function') {
|
||||
originalUpdatePrice(price, animate);
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor calculator interactions
|
||||
const calculator = document.querySelector('[data-calculator]') || document.querySelector('.calculator-container');
|
||||
if (calculator) {
|
||||
// Show price display when user interacts with calculator
|
||||
const inputs = calculator.querySelectorAll('input, select, button');
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('change', showPriceDisplay);
|
||||
input.addEventListener('click', showPriceDisplay);
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced visibility on mobile
|
||||
function handleMobileVisibility() {
|
||||
if (window.innerWidth <= 1024) {
|
||||
priceDisplay.classList.remove('fixed');
|
||||
priceDisplay.classList.add('relative');
|
||||
} else {
|
||||
priceDisplay.classList.remove('relative');
|
||||
priceDisplay.classList.add('fixed');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check and resize listener
|
||||
handleMobileVisibility();
|
||||
window.addEventListener('resize', handleMobileVisibility);
|
||||
|
||||
// Smooth reveal animation
|
||||
setTimeout(() => {
|
||||
if (priceDisplay && !priceDisplay.classList.contains('hidden')) {
|
||||
priceDisplay.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Auto-show after initial page load
|
||||
setTimeout(showPriceDisplay, 1000);
|
||||
});
|
||||
|
||||
// Helper function for other scripts to trigger price display
|
||||
window.showStickyPrice = function() {
|
||||
const priceDisplay = document.getElementById('priceDisplay');
|
||||
if (priceDisplay) {
|
||||
priceDisplay.classList.remove('hidden');
|
||||
priceDisplay.classList.add('visible');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user