@gleb.askerko/componentkit-js
Version:
Lightweight, framework-agnostic JavaScript component library with progress gift components
577 lines (458 loc) • 21.7 kB
JavaScript
// В 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;
}
}