react-achievements 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web.esm.js CHANGED
@@ -1,6 +1,7 @@
1
- import { AchievementEngine } from 'achievements-engine';
1
+ import { AchievementEngine, isSimpleConfig, normalizeAchievements } from 'achievements-engine';
2
2
  export { AchievementBuilder, AchievementEngine, AchievementError, AsyncStorageAdapter, ConfigurationError, ImportValidationError, IndexedDBStorage, LocalStorage, MemoryStorage, OfflineQueueStorage, RestApiStorage, StorageError, StorageQuotaError, StorageType, SyncError, createConfigHash, exportAchievementData, importAchievementData, isAchievementError, isRecoverableError, isSimpleConfig, normalizeAchievements } from 'achievements-engine';
3
- import React, { createContext, useState, useCallback, useEffect, useContext, useMemo, useRef } from 'react';
3
+ import React, { createContext, useState, useCallback, useEffect, useContext, useRef, useMemo } from 'react';
4
+ import confetti from 'canvas-confetti';
4
5
 
5
6
  // Type guard to detect async storage
6
7
  function isAsyncStorage(storage) {
@@ -213,7 +214,7 @@ const builtInThemes = {
213
214
  },
214
215
  confetti: {
215
216
  colors: ['#FFD700', '#4CAF50', '#2196F3', '#FF6B6B'],
216
- particleCount: 50,
217
+ particleCount: 80,
217
218
  shapes: ['circle', 'square'],
218
219
  },
219
220
  },
@@ -245,7 +246,7 @@ const builtInThemes = {
245
246
  },
246
247
  confetti: {
247
248
  colors: ['#4CAF50', '#2196F3'],
248
- particleCount: 30,
249
+ particleCount: 40,
249
250
  shapes: ['circle'],
250
251
  },
251
252
  },
@@ -280,7 +281,7 @@ const builtInThemes = {
280
281
  },
281
282
  confetti: {
282
283
  colors: ['#22d3ee', '#f97316', '#a855f7', '#eab308'], // Cyan, orange, purple, yellow
283
- particleCount: 100,
284
+ particleCount: 120,
284
285
  shapes: ['circle', 'square'],
285
286
  },
286
287
  },
@@ -316,24 +317,36 @@ const BuiltInNotification = ({ achievement, onClose, duration = 5000, position =
316
317
  var _a, _b, _c;
317
318
  const [isVisible, setIsVisible] = useState(false);
318
319
  const [isExiting, setIsExiting] = useState(false);
320
+ const onCloseRef = useRef(onClose);
321
+ const exitTimerRef = useRef(null);
319
322
  // Merge custom icons with defaults
320
323
  const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
321
324
  // Get theme configuration
322
325
  const themeConfig = getTheme(theme) || builtInThemes.modern;
323
326
  const { notification: themeStyles } = themeConfig;
327
+ useEffect(() => {
328
+ onCloseRef.current = onClose;
329
+ }, [onClose]);
330
+ const closeAfterExit = useCallback(() => {
331
+ setIsExiting(true);
332
+ if (exitTimerRef.current) {
333
+ clearTimeout(exitTimerRef.current);
334
+ }
335
+ exitTimerRef.current = setTimeout(() => { var _a; return (_a = onCloseRef.current) === null || _a === void 0 ? void 0 : _a.call(onCloseRef); }, 300);
336
+ }, []);
324
337
  useEffect(() => {
325
338
  // Slide in animation
326
339
  const showTimer = setTimeout(() => setIsVisible(true), 10);
327
340
  // Auto-dismiss
328
- const dismissTimer = setTimeout(() => {
329
- setIsExiting(true);
330
- setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
331
- }, duration);
341
+ const dismissTimer = setTimeout(closeAfterExit, duration);
332
342
  return () => {
333
343
  clearTimeout(showTimer);
334
344
  clearTimeout(dismissTimer);
345
+ if (exitTimerRef.current) {
346
+ clearTimeout(exitTimerRef.current);
347
+ }
335
348
  };
336
- }, [duration, onClose]);
349
+ }, [duration, closeAfterExit]);
337
350
  const getPositionStyles = () => {
338
351
  const stackedOffset = 20 + stackIndex * 104;
339
352
  const base = {
@@ -421,132 +434,162 @@ const BuiltInNotification = ({ achievement, onClose, duration = 5000, position =
421
434
  React.createElement("div", { style: headerStyles }, "Achievement Unlocked!"),
422
435
  React.createElement("div", { style: titleStyles }, achievement.achievementTitle),
423
436
  achievement.achievementDescription && (React.createElement("div", { style: descriptionStyles }, achievement.achievementDescription))),
424
- React.createElement("button", { onClick: () => {
425
- setIsExiting(true);
426
- setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
427
- }, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close notification" }, "\u00D7")));
437
+ React.createElement("button", { onClick: closeAfterExit, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close notification" }, "\u00D7")));
428
438
  };
429
439
 
440
+ const DEFAULT_COLORS = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
441
+ const DEFAULT_DURATION_MS = 5000;
442
+ const DEFAULT_PARTICLE_COUNT = 80;
443
+ const DEFAULT_SHAPES = ['square', 'circle'];
444
+ const DEFAULT_SPREAD = 70;
445
+ const DEFAULT_START_VELOCITY = 45;
446
+ const DEFAULT_GRAVITY = 1;
447
+ const DEFAULT_SCALAR = 1;
448
+ const DEFAULT_Z_INDEX = 10001;
449
+ const getSafeParticleCount = (count) => Math.max(0, Math.floor(count));
430
450
  /**
431
- * Hook to track window dimensions
432
- * Replacement for react-use's useWindowSize
433
- *
434
- * @returns Object with width and height properties
435
- *
436
- * @example
437
- * ```tsx
438
- * const { width, height } = useWindowSize();
439
- * console.log(`Window size: ${width}x${height}`);
440
- * ```
451
+ * Built-in confetti component
452
+ * Canvas-based confetti animation powered by canvas-confetti.
441
453
  */
442
- function useWindowSize() {
443
- const [size, setSize] = useState({
444
- width: typeof window !== 'undefined' ? window.innerWidth : 0,
445
- height: typeof window !== 'undefined' ? window.innerHeight : 0,
446
- });
454
+ const BuiltInConfetti = ({ show, duration = DEFAULT_DURATION_MS, particleCount = DEFAULT_PARTICLE_COUNT, colors = DEFAULT_COLORS, shapes = DEFAULT_SHAPES, spread = DEFAULT_SPREAD, startVelocity = DEFAULT_START_VELOCITY, gravity = DEFAULT_GRAVITY, scalar = DEFAULT_SCALAR, zIndex = DEFAULT_Z_INDEX, }) => {
455
+ const [isVisible, setIsVisible] = useState(false);
447
456
  useEffect(() => {
448
- // Handle SSR - window may not be defined
449
- if (typeof window === 'undefined') {
457
+ if (!show) {
458
+ setIsVisible(false);
459
+ confetti.reset();
450
460
  return;
451
461
  }
452
- const handleResize = () => {
453
- setSize({
454
- width: window.innerWidth,
455
- height: window.innerHeight,
456
- });
462
+ setIsVisible(true);
463
+ const totalParticles = getSafeParticleCount(particleCount);
464
+ const resolvedColors = colors.length > 0 ? colors : DEFAULT_COLORS;
465
+ const resolvedShapes = shapes.length > 0 ? shapes : DEFAULT_SHAPES;
466
+ const baseOptions = {
467
+ colors: resolvedColors,
468
+ shapes: resolvedShapes,
469
+ spread,
470
+ startVelocity,
471
+ gravity,
472
+ scalar,
473
+ zIndex,
474
+ disableForReducedMotion: true,
457
475
  };
458
- // Set initial size
459
- handleResize();
460
- // Add event listener
461
- window.addEventListener('resize', handleResize);
462
- // Cleanup
463
- return () => {
464
- window.removeEventListener('resize', handleResize);
465
- };
466
- }, []);
467
- return size;
468
- }
469
-
470
- /**
471
- * Built-in confetti component
472
- * Lightweight CSS-based confetti animation
473
- */
474
- const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'], }) => {
475
- const [isVisible, setIsVisible] = useState(false);
476
- const { width, height } = useWindowSize();
477
- useEffect(() => {
478
- if (show) {
479
- setIsVisible(true);
480
- const timer = setTimeout(() => setIsVisible(false), duration);
481
- return () => clearTimeout(timer);
476
+ if (totalParticles > 0) {
477
+ const centerParticles = Math.ceil(totalParticles * 0.5);
478
+ const leftParticles = Math.floor((totalParticles - centerParticles) / 2);
479
+ const rightParticles = totalParticles - centerParticles - leftParticles;
480
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: centerParticles, origin: { x: 0.5, y: 0.58 } }));
481
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: leftParticles, angle: 60, spread: Math.max(45, spread * 0.8), origin: { x: 0, y: 0.72 } }));
482
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: rightParticles, angle: 120, spread: Math.max(45, spread * 0.8), origin: { x: 1, y: 0.72 } }));
482
483
  }
483
- else {
484
+ const timer = setTimeout(() => {
484
485
  setIsVisible(false);
485
- }
486
- }, [show, duration]);
486
+ confetti.reset();
487
+ }, duration);
488
+ return () => {
489
+ clearTimeout(timer);
490
+ confetti.reset();
491
+ };
492
+ }, [
493
+ show,
494
+ duration,
495
+ particleCount,
496
+ colors,
497
+ shapes,
498
+ spread,
499
+ startVelocity,
500
+ gravity,
501
+ scalar,
502
+ zIndex,
503
+ ]);
487
504
  if (!isVisible)
488
505
  return null;
489
- const containerStyles = {
490
- position: 'fixed',
491
- top: 0,
492
- left: 0,
493
- width: '100%',
494
- height: '100%',
495
- pointerEvents: 'none',
496
- zIndex: 10001,
497
- overflow: 'hidden',
498
- };
499
- // Generate particles
500
- const particles = Array.from({ length: particleCount }, (_, i) => {
501
- const color = colors[Math.floor(Math.random() * colors.length)];
502
- const startX = Math.random() * width;
503
- const rotation = Math.random() * 360;
504
- const fallDuration = 3 + Math.random() * 2; // 3-5 seconds
505
- const delay = Math.random() * 0.5; // 0-0.5s delay
506
- const shape = Math.random() > 0.5 ? 'circle' : 'square';
507
- const particleStyles = {
508
- position: 'absolute',
509
- top: '-20px',
510
- left: `${startX}px`,
511
- width: '10px',
512
- height: '10px',
513
- backgroundColor: color,
514
- borderRadius: shape === 'circle' ? '50%' : '0',
515
- transform: `rotate(${rotation}deg)`,
516
- animation: `confettiFall ${fallDuration}s linear ${delay}s forwards`,
517
- opacity: 0.9,
518
- };
519
- return React.createElement("div", { key: i, style: particleStyles, "data-testid": "confetti-particle" });
520
- });
521
- return (React.createElement(React.Fragment, null,
522
- React.createElement("style", null, `
523
- @keyframes confettiFall {
524
- 0% {
525
- transform: translateY(0) rotate(0deg);
526
- opacity: 1;
506
+ return React.createElement("div", { "data-testid": "built-in-confetti", style: { display: 'none' } });
507
+ };
508
+
509
+ const DEFAULT_NOTIFICATION_DURATION_MS = 5000;
510
+ const CONFETTI_DURATION_MS = 5000;
511
+ const hasConfiguredConfetti = (confetti) => {
512
+ return confetti === false || (typeof confetti === 'object' && confetti !== null);
513
+ };
514
+ const collectComplexConfetti = (achievements, confettiByAchievementId) => {
515
+ Object.values(achievements).forEach((metricAchievements) => {
516
+ metricAchievements.forEach(({ achievementDetails }) => {
517
+ const confetti = achievementDetails.confetti;
518
+ if (hasConfiguredConfetti(confetti)) {
519
+ confettiByAchievementId.set(achievementDetails.achievementId, confetti);
527
520
  }
528
- 100% {
529
- transform: translateY(${height + 50}px) rotate(720deg);
530
- opacity: 0;
521
+ });
522
+ });
523
+ };
524
+ const simpleConfigHasRewardConfetti = (achievements) => {
525
+ return Object.values(achievements).some((metricAchievements) => Object.values(metricAchievements).some((achievement) => hasConfiguredConfetti(achievement.confetti)));
526
+ };
527
+ const prepareAchievementsForConfetti = (achievements) => {
528
+ const confettiByAchievementId = new Map();
529
+ if (!achievements) {
530
+ return { achievements, confettiByAchievementId };
531
+ }
532
+ if (!isSimpleConfig(achievements)) {
533
+ collectComplexConfetti(achievements, confettiByAchievementId);
534
+ return { achievements, confettiByAchievementId };
535
+ }
536
+ const simpleAchievements = achievements;
537
+ if (!simpleConfigHasRewardConfetti(simpleAchievements)) {
538
+ return { achievements, confettiByAchievementId };
539
+ }
540
+ const normalizedAchievements = normalizeAchievements(simpleAchievements);
541
+ Object.entries(simpleAchievements).forEach(([metric, metricAchievements]) => {
542
+ const normalizedMetricAchievements = normalizedAchievements[metric] || [];
543
+ Object.values(metricAchievements).forEach((achievement, index) => {
544
+ const confetti = achievement.confetti;
545
+ const normalizedAchievement = normalizedMetricAchievements[index];
546
+ if (normalizedAchievement && hasConfiguredConfetti(confetti)) {
547
+ normalizedAchievement.achievementDetails.confetti = confetti;
548
+ confettiByAchievementId.set(normalizedAchievement.achievementDetails.achievementId, confetti);
531
549
  }
532
- }
533
- `),
534
- React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
550
+ });
551
+ });
552
+ return { achievements: normalizedAchievements, confettiByAchievementId };
535
553
  };
536
-
537
- const NOTIFICATION_DURATION_MS = 5000;
538
554
  const AchievementUIContext = createContext({
539
555
  icons: {},
540
556
  ui: {},
541
557
  });
542
- const AchievementEffects = ({ icons, ui }) => {
558
+ const AchievementEffects = ({ icons, ui, achievementConfetti }) => {
559
+ var _a, _b;
543
560
  const engine = useAchievementEngine();
544
561
  const seenAchievementsRef = useRef(new Set(engine.getUnlocked()));
545
562
  const confettiTimerRef = useRef(null);
563
+ const achievementConfettiRef = useRef(achievementConfetti);
546
564
  const [showConfetti, setShowConfetti] = useState(false);
565
+ const [activeConfettiConfig, setActiveConfettiConfig] = useState(null);
547
566
  const [notifications, setNotifications] = useState([]);
567
+ const notificationDuration = (_a = ui.notificationDuration) !== null && _a !== void 0 ? _a : DEFAULT_NOTIFICATION_DURATION_MS;
568
+ const themeName = ui.theme || 'modern';
569
+ const themeConfig = useMemo(() => getTheme(themeName) || builtInThemes.modern, [themeName]);
570
+ const globalConfettiConfig = useMemo(() => (Object.assign(Object.assign({}, themeConfig.confetti), ui.confetti)), [themeConfig, ui.confetti]);
571
+ const globalConfettiConfigRef = useRef(globalConfettiConfig);
572
+ const renderedConfettiConfig = showConfetti && activeConfettiConfig ? activeConfettiConfig : globalConfettiConfig;
573
+ const renderedConfettiDuration = (_b = renderedConfettiConfig.duration) !== null && _b !== void 0 ? _b : CONFETTI_DURATION_MS;
574
+ useEffect(() => {
575
+ achievementConfettiRef.current = achievementConfetti;
576
+ }, [achievementConfetti]);
577
+ useEffect(() => {
578
+ globalConfettiConfigRef.current = globalConfettiConfig;
579
+ }, [globalConfettiConfig]);
580
+ useEffect(() => {
581
+ if (ui.enableConfetti === false) {
582
+ if (confettiTimerRef.current) {
583
+ clearTimeout(confettiTimerRef.current);
584
+ confettiTimerRef.current = null;
585
+ }
586
+ setShowConfetti(false);
587
+ setActiveConfettiConfig(null);
588
+ }
589
+ }, [ui.enableConfetti]);
548
590
  useEffect(() => {
549
591
  const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
592
+ var _a;
550
593
  if (seenAchievementsRef.current.has(event.achievementId)) {
551
594
  return;
552
595
  }
@@ -566,15 +609,19 @@ const AchievementEffects = ({ icons, ui }) => {
566
609
  return [...currentNotifications, unlockedAchievement];
567
610
  });
568
611
  }
569
- if (ui.enableConfetti !== false) {
612
+ const rewardConfetti = achievementConfettiRef.current.get(event.achievementId);
613
+ if (ui.enableConfetti !== false && rewardConfetti !== false) {
570
614
  if (confettiTimerRef.current) {
571
615
  clearTimeout(confettiTimerRef.current);
572
616
  }
617
+ const resolvedConfettiConfig = Object.assign(Object.assign({}, globalConfettiConfigRef.current), (rewardConfetti || {}));
618
+ const resolvedConfettiDuration = (_a = resolvedConfettiConfig.duration) !== null && _a !== void 0 ? _a : CONFETTI_DURATION_MS;
619
+ setActiveConfettiConfig(resolvedConfettiConfig);
573
620
  setShowConfetti(true);
574
621
  confettiTimerRef.current = setTimeout(() => {
575
622
  setShowConfetti(false);
576
623
  confettiTimerRef.current = null;
577
- }, NOTIFICATION_DURATION_MS);
624
+ }, resolvedConfettiDuration);
578
625
  }
579
626
  });
580
627
  const unsubscribeStateChanged = engine.on('state:changed', () => {
@@ -597,19 +644,20 @@ const AchievementEffects = ({ icons, ui }) => {
597
644
  const ConfettiComponentResolved = ui.ConfettiComponent || BuiltInConfetti;
598
645
  return (React.createElement(React.Fragment, null,
599
646
  ui.enableNotifications !== false &&
600
- notifications.map((notification, index) => (React.createElement(NotificationComponent, { key: notification.achievementId, achievement: notification, onClose: () => setNotifications((currentNotifications) => currentNotifications.filter((currentNotification) => currentNotification.achievementId !== notification.achievementId)), duration: NOTIFICATION_DURATION_MS, position: ui.notificationPosition || 'top-center', theme: ui.theme || 'modern', icons: icons, stackIndex: index }))),
601
- ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: NOTIFICATION_DURATION_MS }))));
647
+ notifications.map((notification, index) => (React.createElement(NotificationComponent, { key: notification.achievementId, achievement: notification, onClose: () => setNotifications((currentNotifications) => currentNotifications.filter((currentNotification) => currentNotification.achievementId !== notification.achievementId)), duration: notificationDuration, position: ui.notificationPosition || 'top-center', theme: ui.theme || 'modern', icons: icons, stackIndex: index }))),
648
+ ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, Object.assign({ show: showConfetti }, renderedConfettiConfig, { duration: renderedConfettiDuration })))));
602
649
  };
603
650
  const AchievementProvider = (_a) => {
604
651
  var { children, icons = {}, ui = {}, useBuiltInUI } = _a, providerProps = __rest(_a, ["children", "icons", "ui", "useBuiltInUI"]);
605
652
  if (useBuiltInUI !== undefined) {
606
653
  warnDeprecation('`useBuiltInUI` is deprecated and is now a no-op because built-in UI is the default. It will be removed in 5.0.');
607
654
  }
655
+ const [{ achievements: preparedAchievements, confettiByAchievementId }] = useState(() => prepareAchievementsForConfetti(providerProps.achievements));
608
656
  const uiContextValue = useMemo(() => ({ icons, ui }), [icons, ui]);
609
657
  return (React.createElement(AchievementUIContext.Provider, { value: uiContextValue },
610
- React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { icons: icons }),
658
+ React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { achievements: preparedAchievements, icons: icons }),
611
659
  children,
612
- React.createElement(AchievementEffects, { icons: icons, ui: ui }))));
660
+ React.createElement(AchievementEffects, { achievementConfetti: confettiByAchievementId, icons: icons, ui: ui }))));
613
661
  };
614
662
 
615
663
  const useAchievements = () => {
@@ -1385,5 +1433,45 @@ const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'mode
1385
1433
  })))))));
1386
1434
  };
1387
1435
 
1436
+ /**
1437
+ * Hook to track window dimensions
1438
+ * Replacement for react-use's useWindowSize
1439
+ *
1440
+ * @returns Object with width and height properties
1441
+ *
1442
+ * @example
1443
+ * ```tsx
1444
+ * const { width, height } = useWindowSize();
1445
+ * console.log(`Window size: ${width}x${height}`);
1446
+ * ```
1447
+ */
1448
+ function useWindowSize() {
1449
+ const [size, setSize] = useState({
1450
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
1451
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
1452
+ });
1453
+ useEffect(() => {
1454
+ // Handle SSR - window may not be defined
1455
+ if (typeof window === 'undefined') {
1456
+ return;
1457
+ }
1458
+ const handleResize = () => {
1459
+ setSize({
1460
+ width: window.innerWidth,
1461
+ height: window.innerHeight,
1462
+ });
1463
+ };
1464
+ // Set initial size
1465
+ handleResize();
1466
+ // Add event listener
1467
+ window.addEventListener('resize', handleResize);
1468
+ // Cleanup
1469
+ return () => {
1470
+ window.removeEventListener('resize', handleResize);
1471
+ };
1472
+ }, []);
1473
+ return size;
1474
+ }
1475
+
1388
1476
  export { AchievementContext, AchievementProvider, AchievementsList, AchievementsModal, AchievementsWidget, BadgesButton, BadgesButtonWithModal, BadgesModal, BuiltInConfetti, BuiltInModal, BuiltInNotification, ConfettiWrapper, AchievementProvider$1 as HeadlessAchievementProvider, LevelProgress, defaultAchievementIcons, defaultStyles, isAsyncStorage, useAchievementEngine, useAchievementState, useAchievements, useSimpleAchievements, useWindowSize };
1389
1477
  //# sourceMappingURL=web.esm.js.map