UNPKG

@gleb.askerko/componentkit-js

Version:

Lightweight, framework-agnostic JavaScript component library with progress gift components

577 lines (458 loc) 21.7 kB
// В NPM пакете package.json будет в корне import packageJson from '../../package.json' assert { type: 'json' }; export class ProgressGift { static version = packageJson.version; constructor(options = {}) { this.options = { maxPoints: 100, currentPoints: 0, giftIcon: '🎁', showGiftCount: true, animationDuration: 300, className: '', showVersion: true, // Show version by default onGiftEarned: null, ...options }; this.element = null; this.progressBar = null; this.progressFill = null; this.pointsText = null; this.giftCountText = null; this.giftIcon = null; this.container = null; this.currentPoints = this.options.currentPoints; this.giftsEarned = 0; this.visibleGifts = 0; // Количество видимых подарков (прилетевших) this.isAnimating = false; } render(selector) { this.container = typeof selector === 'string' ? document.querySelector(selector) : selector; if (!this.container) { throw new Error(`Container not found: ${selector}`); } this.element = this.createElement(); this.container.appendChild(this.element); this.updateDisplay(); // Добавляем обработчик изменения размера для пересчета количества подарков this.setupResizeObserver(); return this; } setupResizeObserver() { if (typeof ResizeObserver !== 'undefined' && this.element) { this.resizeObserver = new ResizeObserver(() => { // Пересчитываем отображение подарков при изменении размера if (this.giftsEarned > 0) { this.updateGiftsDisplay(); } }); this.resizeObserver.observe(this.element); } } createElement() { const wrapper = document.createElement('div'); wrapper.className = this.getWrapperClasses(); // Progress bar container with gift icon on the right const progressContainer = document.createElement('div'); progressContainer.className = 'ck-progress-gift-container'; // Progress bar and gift icon in a row const progressRow = document.createElement('div'); progressRow.className = 'ck-progress-gift-row'; this.progressBar = document.createElement('div'); this.progressBar.className = 'ck-progress-gift-bar'; this.progressFill = document.createElement('div'); this.progressFill.className = 'ck-progress-gift-fill'; this.progressBar.appendChild(this.progressFill); // Points text (теперь поверх прогресс-бара) this.pointsText = document.createElement('div'); this.pointsText.className = 'ck-progress-gift-text'; this.progressBar.appendChild(this.pointsText); // Gift icon (справа от прогресс-бара) this.giftIcon = document.createElement('div'); this.giftIcon.className = 'ck-progress-gift-icon'; this.giftIcon.textContent = this.options.giftIcon; progressRow.appendChild(this.progressBar); progressRow.appendChild(this.giftIcon); progressContainer.appendChild(progressRow); // Gifts earned display (под прогресс-баром) this.giftsRow = document.createElement('div'); this.giftsRow.className = 'ck-progress-gifts-row'; progressContainer.appendChild(this.giftsRow); // Version display (в правом верхнем углу компонента) if (this.options.showVersion) { const versionLabel = document.createElement('div'); versionLabel.className = 'ck-progress-version'; versionLabel.textContent = `v${ProgressGift.version}`; wrapper.appendChild(versionLabel); } wrapper.appendChild(progressContainer); return wrapper; } getWrapperClasses() { return [ 'ck-progress-gift-wrapper', this.options.className ].filter(Boolean).join(' '); } addPoints(points) { if (this.isAnimating || points <= 0) return this; console.log('🔥 ADDING POINTS:', { pointsToAdd: points, currentPoints: this.currentPoints, giftsEarned: this.giftsEarned, newTotal: this.currentPoints + points, isAnimating: this.isAnimating }); this.isAnimating = true; const previousPoints = this.currentPoints; const newPoints = this.currentPoints + points; // Анимируем добавление очков this.animateProgress(previousPoints, newPoints); return this; } // Set absolute points value (no automatic logic) setPoints(targetPoints) { if (this.isAnimating) return this; console.log('setPoints called', { oldPoints: this.currentPoints, newPoints: targetPoints, giftsEarned: this.giftsEarned }); // Анимируем от текущих очков к целевым this.animateProgress(this.currentPoints, targetPoints); return this; } animateProgress(startPoints, endPoints) { const duration = this.options.animationDuration; const startTime = performance.now(); const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Используем ease-in-out для более плавной анимации без долгого разгона const easedProgress = progress < 0.5 ? 2 * progress * progress : 1 - 2 * (1 - progress) * (1 - progress); // Интерполяция очков с улучшенной кривой const currentPoints = startPoints + (endPoints - startPoints) * easedProgress; this.currentPoints = currentPoints; this.updateDisplay(); if (progress < 1) { requestAnimationFrame(animate); } else { this.currentPoints = endPoints; this.isAnimating = false; // Проверяем подарки после завершения анимации this.checkForGift(); } }; requestAnimationFrame(animate); } checkForGift() { console.log('checkForGift called', { currentPoints: this.currentPoints, maxPoints: this.options.maxPoints, shouldEarnGift: this.currentPoints >= this.options.maxPoints }); // Процессируем все подарки за раз для больших значений очков while (this.currentPoints >= this.options.maxPoints) { console.log('Processing gift, current points:', this.currentPoints); this.earnGift(); } } earnGift() { this.giftsEarned++; const previousPoints = this.currentPoints; this.currentPoints = this.currentPoints - this.options.maxPoints; console.log('🎁 GIFT EARNED!', { giftNumber: this.giftsEarned, remainingPoints: this.currentPoints, nextGiftNeeds: this.options.maxPoints - this.currentPoints + ' more points' }); // Делаем основную иконку полностью невидимой на время полета this.giftIcon.style.opacity = '0'; // Создаем летящую иконку подарка сразу this.createFlyingGift(); // Если есть остаток очков, анимируем от 0 до остатка if (this.currentPoints > 0) { // Временно устанавливаем 0 для анимации const targetPoints = this.currentPoints; this.currentPoints = 0; this.updateDisplay(); // Запускаем анимацию к остатку setTimeout(() => { this.animateProgress(0, targetPoints); }, 100); // Небольшая задержка для визуального эффекта } else { // Если остатка нет, просто обновляем this.updateDisplay(); } // Вызываем колбэк если есть if (this.options.onGiftEarned) { this.options.onGiftEarned(this.giftsEarned, this.currentPoints); } } createFlyingGift() { // Получаем позицию основной иконки подарка const giftIconRect = this.giftIcon.getBoundingClientRect(); const containerRect = this.element.getBoundingClientRect(); // Вычисляем позицию целевой иконки в ряду const targetPosition = this.calculateTargetPosition(); // Корректируем финальную позицию по центру целевой иконки, но чуть выше const finalX = targetPosition.x; // центр по горизонтали const finalY = targetPosition.y - 12; // чуть выше центра // Создаем летящую иконку const flyingGift = document.createElement('span'); flyingGift.textContent = this.options.giftIcon; flyingGift.className = 'ck-progress-gift-flying'; // Начальная позиция (от основной иконки) const startX = giftIconRect.left - containerRect.left; const startY = giftIconRect.top - containerRect.top; // Конечная позиция (правый верхний угол от целевой иконки) const endX = finalX; const endY = finalY; // Устанавливаем CSS переменные для анимации (в пикселях без позиционирования) flyingGift.style.setProperty('--start-x', startX + 'px'); flyingGift.style.setProperty('--start-y', startY + 'px'); flyingGift.style.setProperty('--end-x', endX + 'px'); flyingGift.style.setProperty('--end-y', endY + 'px'); // Абсолютное позиционирование для анимации flyingGift.style.position = 'absolute'; flyingGift.style.left = '0px'; flyingGift.style.top = '0px'; // Вычисляем расстояние для корректировки скорости const distance = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2)); const baseDistance = 600; // базовое расстояние для 1 секунды (увеличено в 2 раза для большей скорости) const calculatedDuration = (distance / baseDistance) * 1.0; const duration = Math.min(Math.max(0.5, calculatedDuration), 1.0); // минимум 0.5с, максимум 1.0с console.log('Flying gift created:', { startX, startY, endX, endY, distance, duration }); this.element.appendChild(flyingGift); // Запускаем анимацию с динамической длительностью flyingGift.style.animation = `giftFly ${duration}s ease-out forwards`; // Удаляем летящую иконку в конце анимации с динамическим таймингом setTimeout(() => { flyingGift.style.display = 'none'; flyingGift.remove(); this.showNewGiftInRow(); // Возвращаем видимость основной иконке this.giftIcon.style.opacity = '1'; }, duration * 1000); } calculateTargetPosition() { if (!this.giftsRow) { return { x: 0, y: 0 }; } const containerRect = this.element.getBoundingClientRect(); const giftsRowRect = this.giftsRow.getBoundingClientRect(); // Позиция начала ряда подарков относительно контейнера const rowStartX = giftsRowRect.left - containerRect.left; const rowStartY = giftsRowRect.top - containerRect.top; const maxGiftsToShow = this.calculateMaxGiftsToShow(); // Если подарков больше лимита, летим к позиции счетчика переполнения if (this.giftsEarned > maxGiftsToShow) { // Размер одной иконки и отступы const iconSize = 18; // 1.125rem ≈ 18px const gap = 4; // 0.25rem = 4px // Позиция после последней (12-й) иконки, где находится текст +N const targetX = rowStartX + (maxGiftsToShow * (iconSize + gap)) + 20; // +20px для центра текста const targetY = rowStartY + (iconSize / 2); // центр по вертикали return { x: targetX, y: targetY }; } else { // Летим к позиции следующей иконки const iconSize = 18; const gap = 4; const targetIconIndex = this.visibleGifts; // индекс будущей видимой иконки const targetX = rowStartX + (targetIconIndex * (iconSize + gap)); const targetY = rowStartY + (iconSize / 2); return { x: targetX, y: targetY }; } } showNewGiftInRow() { // Увеличиваем счетчик видимых подарков this.visibleGifts++; // Находим все иконки подарков в ряду const giftIcons = this.giftsRow.querySelectorAll('.ck-progress-gift-earned'); // Показываем последнюю добавленную (которая соответствует только что заработанному подарку) if (giftIcons.length >= this.visibleGifts) { const targetIcon = giftIcons[this.visibleGifts - 1]; if (targetIcon) { targetIcon.classList.add('ck-progress-gift-earned--visible'); console.log('Gift appeared in row:', this.visibleGifts); } } // Обновляем счетчик переполнения только когда подарок появляется this.updateOverflowCounter(); } updateDisplay() { const percentage = Math.min((this.currentPoints / this.options.maxPoints) * 100, 100); // Обновляем прогресс бар if (this.progressFill) { this.progressFill.style.width = `${percentage}%`; } // Обновляем текст очков - всегда показываем проценты с округлением if (this.pointsText) { // Обеспечиваем корректное отображение процентов (0-100%) const displayPercentage = Math.max(0, Math.min(100, Math.round(percentage))); this.pointsText.textContent = `${displayPercentage}%`; } // Обновляем отображение заработанных подарков this.updateGiftsDisplay(); } calculateMaxGiftsToShow() { if (!this.giftsRow || !this.element) { return 8; // Уменьшаем значение по умолчанию } // Получаем ширину контейнера подарков const containerWidth = this.element.offsetWidth; const giftsRowStyles = window.getComputedStyle(this.giftsRow); const padding = parseFloat(giftsRowStyles.paddingLeft) + parseFloat(giftsRowStyles.paddingRight) || 0; const availableWidth = containerWidth - padding - 32; // 32px на общие отступы // Размеры иконки и отступов (берем из CSS) const iconSize = 18; // 1.125rem ≈ 18px const gap = 4; // 0.25rem = 4px между иконками const overflowTextWidth = 50; // ширина "+N" текста с отступами // Рассчитываем максимальное количество подарков с запасом для "+N" const maxGifts = Math.floor((availableWidth - overflowTextWidth) / (iconSize + gap)); // Более консервативные ограничения для мобильных устройств let result; if (containerWidth <= 320) { result = Math.max(2, Math.min(maxGifts, 4)); // Мобильная: 2-4 подарка } else if (containerWidth <= 480) { result = Math.max(3, Math.min(maxGifts, 6)); // Планшет: 3-6 подарков } else if (containerWidth <= 600) { result = Math.max(4, Math.min(maxGifts, 8)); // Обычная: 4-8 подарков } else { result = Math.max(6, Math.min(maxGifts, 12)); // Большая: 6-12 подарков } console.log('Dynamic gifts calculation:', { containerWidth, availableWidth, maxGifts, result, breakpoint: containerWidth <= 320 ? 'mobile' : containerWidth <= 480 ? 'tablet' : containerWidth <= 600 ? 'normal' : 'large' }); return result; } updateGiftsDisplay() { if (!this.giftsRow) { console.log('No giftsRow found'); return; } console.log('Updating gifts display', { giftsEarned: this.giftsEarned, visibleGifts: this.visibleGifts }); // Всегда показываем ряд подарков для сохранения высоты this.giftsRow.style.display = 'flex'; this.giftsRow.innerHTML = ''; // Если подарков нет, оставляем пустой ряд if (this.giftsEarned === 0) { console.log('No gifts to show'); return; } const maxGiftsToShow = this.calculateMaxGiftsToShow(); // Динамический расчет const giftsToShow = Math.min(this.giftsEarned, maxGiftsToShow); console.log('Creating gift icons', { giftsToShow }); // Добавляем иконки подарков (все заработанные, но видимыми будут только прилетевшие) for (let i = 0; i < giftsToShow; i++) { const giftIcon = document.createElement('span'); giftIcon.textContent = this.options.giftIcon; giftIcon.className = 'ck-progress-gift-earned'; // Показываем только те подарки, которые уже "прилетели" if (i < this.visibleGifts) { giftIcon.classList.add('ck-progress-gift-earned--visible'); } this.giftsRow.appendChild(giftIcon); } // Восстанавливаем счетчик переполнения если он был this.updateOverflowCounter(); console.log('Gifts row HTML:', this.giftsRow.innerHTML); } updateOverflowCounter() { const maxGiftsToShow = this.calculateMaxGiftsToShow(); // Удаляем старый счетчик если есть const existingOverflow = this.giftsRow.querySelector('.ck-progress-gifts-overflow'); if (existingOverflow) { existingOverflow.remove(); } // Добавляем новый счетчик только если видимых подарков больше лимита if (this.visibleGifts > maxGiftsToShow) { const overflowText = document.createElement('span'); overflowText.textContent = `+${this.visibleGifts - maxGiftsToShow}`; overflowText.className = 'ck-progress-gifts-overflow'; this.giftsRow.appendChild(overflowText); } } setMaxPoints(maxPoints) { this.options.maxPoints = maxPoints; this.updateDisplay(); return this; } getCurrentPoints() { return Math.floor(this.currentPoints); } getGiftsEarned() { return this.giftsEarned; } getPercentage() { return Math.min((this.currentPoints / this.options.maxPoints) * 100, 100); } reset() { console.log('Resetting component'); this.currentPoints = 0; this.giftsEarned = 0; this.visibleGifts = 0; // Сбрасываем видимые подарки this.isAnimating = false; // Сбрасываем флаг анимации this.updateDisplay(); return this; } // Backward compatibility method - alias for reset() initProgressGift() { return this.reset(); } reinitialize() { console.log('Reinitializing component - full state reset'); // Reset all internal state this.currentPoints = 0; this.giftsEarned = 0; this.visibleGifts = 0; // Сбрасываем видимые подарки this.isAnimating = false; // Update display to reflect reset state - this will correctly show 0% this.updateDisplay(); // Call onGiftEarned callback with reset values if it exists if (this.options.onGiftEarned) { this.options.onGiftEarned(0, 0); // Reset gifts and points } // Clear gifts container if exists if (this.giftsContainer) { this.giftsContainer.innerHTML = ''; } // Убираем все классы анимации if (this.giftIcon) { this.giftIcon.classList.remove('ck-progress-gift-icon--earned'); } console.log('Component fully reinitialized'); return this; } setPoints(points) { console.log('setPoints called', { oldPoints: this.currentPoints, newPoints: points, giftsEarned: this.giftsEarned }); this.currentPoints = points; this.updateDisplay(); this.checkForGift(); return this; } update(newOptions) { this.options = { ...this.options, ...newOptions }; if (this.giftIcon && newOptions.giftIcon) { this.giftIcon.textContent = this.options.giftIcon; } this.updateDisplay(); return this; } destroy() { if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } this.element = null; this.progressBar = null; this.progressFill = null; this.pointsText = null; this.giftCountText = null; this.giftIcon = null; this.container = null; } }