react-achievements 4.3.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.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  var achievementsEngine = require('achievements-engine');
4
4
  var React = require('react');
5
+ var confetti = require('canvas-confetti');
5
6
 
6
7
  // Type guard to detect async storage
7
8
  function isAsyncStorage(storage) {
@@ -214,7 +215,7 @@ const builtInThemes = {
214
215
  },
215
216
  confetti: {
216
217
  colors: ['#FFD700', '#4CAF50', '#2196F3', '#FF6B6B'],
217
- particleCount: 50,
218
+ particleCount: 80,
218
219
  shapes: ['circle', 'square'],
219
220
  },
220
221
  },
@@ -246,7 +247,7 @@ const builtInThemes = {
246
247
  },
247
248
  confetti: {
248
249
  colors: ['#4CAF50', '#2196F3'],
249
- particleCount: 30,
250
+ particleCount: 40,
250
251
  shapes: ['circle'],
251
252
  },
252
253
  },
@@ -281,7 +282,7 @@ const builtInThemes = {
281
282
  },
282
283
  confetti: {
283
284
  colors: ['#22d3ee', '#f97316', '#a855f7', '#eab308'], // Cyan, orange, purple, yellow
284
- particleCount: 100,
285
+ particleCount: 120,
285
286
  shapes: ['circle', 'square'],
286
287
  },
287
288
  },
@@ -437,129 +438,159 @@ const BuiltInNotification = ({ achievement, onClose, duration = 5000, position =
437
438
  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")));
438
439
  };
439
440
 
441
+ const DEFAULT_COLORS = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
442
+ const DEFAULT_DURATION_MS = 5000;
443
+ const DEFAULT_PARTICLE_COUNT = 80;
444
+ const DEFAULT_SHAPES = ['square', 'circle'];
445
+ const DEFAULT_SPREAD = 70;
446
+ const DEFAULT_START_VELOCITY = 45;
447
+ const DEFAULT_GRAVITY = 1;
448
+ const DEFAULT_SCALAR = 1;
449
+ const DEFAULT_Z_INDEX = 10001;
450
+ const getSafeParticleCount = (count) => Math.max(0, Math.floor(count));
440
451
  /**
441
- * Hook to track window dimensions
442
- * Replacement for react-use's useWindowSize
443
- *
444
- * @returns Object with width and height properties
445
- *
446
- * @example
447
- * ```tsx
448
- * const { width, height } = useWindowSize();
449
- * console.log(`Window size: ${width}x${height}`);
450
- * ```
452
+ * Built-in confetti component
453
+ * Canvas-based confetti animation powered by canvas-confetti.
451
454
  */
452
- function useWindowSize() {
453
- const [size, setSize] = React.useState({
454
- width: typeof window !== 'undefined' ? window.innerWidth : 0,
455
- height: typeof window !== 'undefined' ? window.innerHeight : 0,
456
- });
455
+ 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, }) => {
456
+ const [isVisible, setIsVisible] = React.useState(false);
457
457
  React.useEffect(() => {
458
- // Handle SSR - window may not be defined
459
- if (typeof window === 'undefined') {
458
+ if (!show) {
459
+ setIsVisible(false);
460
+ confetti.reset();
460
461
  return;
461
462
  }
462
- const handleResize = () => {
463
- setSize({
464
- width: window.innerWidth,
465
- height: window.innerHeight,
466
- });
463
+ setIsVisible(true);
464
+ const totalParticles = getSafeParticleCount(particleCount);
465
+ const resolvedColors = colors.length > 0 ? colors : DEFAULT_COLORS;
466
+ const resolvedShapes = shapes.length > 0 ? shapes : DEFAULT_SHAPES;
467
+ const baseOptions = {
468
+ colors: resolvedColors,
469
+ shapes: resolvedShapes,
470
+ spread,
471
+ startVelocity,
472
+ gravity,
473
+ scalar,
474
+ zIndex,
475
+ disableForReducedMotion: true,
467
476
  };
468
- // Set initial size
469
- handleResize();
470
- // Add event listener
471
- window.addEventListener('resize', handleResize);
472
- // Cleanup
473
- return () => {
474
- window.removeEventListener('resize', handleResize);
475
- };
476
- }, []);
477
- return size;
478
- }
479
-
480
- /**
481
- * Built-in confetti component
482
- * Lightweight CSS-based confetti animation
483
- */
484
- const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'], }) => {
485
- const [isVisible, setIsVisible] = React.useState(false);
486
- const { width, height } = useWindowSize();
487
- React.useEffect(() => {
488
- if (show) {
489
- setIsVisible(true);
490
- const timer = setTimeout(() => setIsVisible(false), duration);
491
- return () => clearTimeout(timer);
477
+ if (totalParticles > 0) {
478
+ const centerParticles = Math.ceil(totalParticles * 0.5);
479
+ const leftParticles = Math.floor((totalParticles - centerParticles) / 2);
480
+ const rightParticles = totalParticles - centerParticles - leftParticles;
481
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: centerParticles, origin: { x: 0.5, y: 0.58 } }));
482
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: leftParticles, angle: 60, spread: Math.max(45, spread * 0.8), origin: { x: 0, y: 0.72 } }));
483
+ confetti(Object.assign(Object.assign({}, baseOptions), { particleCount: rightParticles, angle: 120, spread: Math.max(45, spread * 0.8), origin: { x: 1, y: 0.72 } }));
492
484
  }
493
- else {
485
+ const timer = setTimeout(() => {
494
486
  setIsVisible(false);
495
- }
496
- }, [show, duration]);
487
+ confetti.reset();
488
+ }, duration);
489
+ return () => {
490
+ clearTimeout(timer);
491
+ confetti.reset();
492
+ };
493
+ }, [
494
+ show,
495
+ duration,
496
+ particleCount,
497
+ colors,
498
+ shapes,
499
+ spread,
500
+ startVelocity,
501
+ gravity,
502
+ scalar,
503
+ zIndex,
504
+ ]);
497
505
  if (!isVisible)
498
506
  return null;
499
- const containerStyles = {
500
- position: 'fixed',
501
- top: 0,
502
- left: 0,
503
- width: '100%',
504
- height: '100%',
505
- pointerEvents: 'none',
506
- zIndex: 10001,
507
- overflow: 'hidden',
508
- };
509
- // Generate particles
510
- const particles = Array.from({ length: particleCount }, (_, i) => {
511
- const color = colors[Math.floor(Math.random() * colors.length)];
512
- const startX = Math.random() * width;
513
- const rotation = Math.random() * 360;
514
- const fallDuration = 3 + Math.random() * 2; // 3-5 seconds
515
- const delay = Math.random() * 0.5; // 0-0.5s delay
516
- const shape = Math.random() > 0.5 ? 'circle' : 'square';
517
- const particleStyles = {
518
- position: 'absolute',
519
- top: '-20px',
520
- left: `${startX}px`,
521
- width: '10px',
522
- height: '10px',
523
- backgroundColor: color,
524
- borderRadius: shape === 'circle' ? '50%' : '0',
525
- transform: `rotate(${rotation}deg)`,
526
- animation: `confettiFall ${fallDuration}s linear ${delay}s forwards`,
527
- opacity: 0.9,
528
- };
529
- return React.createElement("div", { key: i, style: particleStyles, "data-testid": "confetti-particle" });
530
- });
531
- return (React.createElement(React.Fragment, null,
532
- React.createElement("style", null, `
533
- @keyframes confettiFall {
534
- 0% {
535
- transform: translateY(0) rotate(0deg);
536
- opacity: 1;
537
- }
538
- 100% {
539
- transform: translateY(${height + 50}px) rotate(720deg);
540
- opacity: 0;
541
- }
542
- }
543
- `),
544
- React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
507
+ return React.createElement("div", { "data-testid": "built-in-confetti", style: { display: 'none' } });
545
508
  };
546
509
 
547
510
  const DEFAULT_NOTIFICATION_DURATION_MS = 5000;
548
511
  const CONFETTI_DURATION_MS = 5000;
512
+ const hasConfiguredConfetti = (confetti) => {
513
+ return confetti === false || (typeof confetti === 'object' && confetti !== null);
514
+ };
515
+ const collectComplexConfetti = (achievements, confettiByAchievementId) => {
516
+ Object.values(achievements).forEach((metricAchievements) => {
517
+ metricAchievements.forEach(({ achievementDetails }) => {
518
+ const confetti = achievementDetails.confetti;
519
+ if (hasConfiguredConfetti(confetti)) {
520
+ confettiByAchievementId.set(achievementDetails.achievementId, confetti);
521
+ }
522
+ });
523
+ });
524
+ };
525
+ const simpleConfigHasRewardConfetti = (achievements) => {
526
+ return Object.values(achievements).some((metricAchievements) => Object.values(metricAchievements).some((achievement) => hasConfiguredConfetti(achievement.confetti)));
527
+ };
528
+ const prepareAchievementsForConfetti = (achievements) => {
529
+ const confettiByAchievementId = new Map();
530
+ if (!achievements) {
531
+ return { achievements, confettiByAchievementId };
532
+ }
533
+ if (!achievementsEngine.isSimpleConfig(achievements)) {
534
+ collectComplexConfetti(achievements, confettiByAchievementId);
535
+ return { achievements, confettiByAchievementId };
536
+ }
537
+ const simpleAchievements = achievements;
538
+ if (!simpleConfigHasRewardConfetti(simpleAchievements)) {
539
+ return { achievements, confettiByAchievementId };
540
+ }
541
+ const normalizedAchievements = achievementsEngine.normalizeAchievements(simpleAchievements);
542
+ Object.entries(simpleAchievements).forEach(([metric, metricAchievements]) => {
543
+ const normalizedMetricAchievements = normalizedAchievements[metric] || [];
544
+ Object.values(metricAchievements).forEach((achievement, index) => {
545
+ const confetti = achievement.confetti;
546
+ const normalizedAchievement = normalizedMetricAchievements[index];
547
+ if (normalizedAchievement && hasConfiguredConfetti(confetti)) {
548
+ normalizedAchievement.achievementDetails.confetti = confetti;
549
+ confettiByAchievementId.set(normalizedAchievement.achievementDetails.achievementId, confetti);
550
+ }
551
+ });
552
+ });
553
+ return { achievements: normalizedAchievements, confettiByAchievementId };
554
+ };
549
555
  const AchievementUIContext = React.createContext({
550
556
  icons: {},
551
557
  ui: {},
552
558
  });
553
- const AchievementEffects = ({ icons, ui }) => {
554
- var _a;
559
+ const AchievementEffects = ({ icons, ui, achievementConfetti }) => {
560
+ var _a, _b;
555
561
  const engine = useAchievementEngine();
556
562
  const seenAchievementsRef = React.useRef(new Set(engine.getUnlocked()));
557
563
  const confettiTimerRef = React.useRef(null);
564
+ const achievementConfettiRef = React.useRef(achievementConfetti);
558
565
  const [showConfetti, setShowConfetti] = React.useState(false);
566
+ const [activeConfettiConfig, setActiveConfettiConfig] = React.useState(null);
559
567
  const [notifications, setNotifications] = React.useState([]);
560
568
  const notificationDuration = (_a = ui.notificationDuration) !== null && _a !== void 0 ? _a : DEFAULT_NOTIFICATION_DURATION_MS;
569
+ const themeName = ui.theme || 'modern';
570
+ const themeConfig = React.useMemo(() => getTheme(themeName) || builtInThemes.modern, [themeName]);
571
+ const globalConfettiConfig = React.useMemo(() => (Object.assign(Object.assign({}, themeConfig.confetti), ui.confetti)), [themeConfig, ui.confetti]);
572
+ const globalConfettiConfigRef = React.useRef(globalConfettiConfig);
573
+ const renderedConfettiConfig = showConfetti && activeConfettiConfig ? activeConfettiConfig : globalConfettiConfig;
574
+ const renderedConfettiDuration = (_b = renderedConfettiConfig.duration) !== null && _b !== void 0 ? _b : CONFETTI_DURATION_MS;
575
+ React.useEffect(() => {
576
+ achievementConfettiRef.current = achievementConfetti;
577
+ }, [achievementConfetti]);
578
+ React.useEffect(() => {
579
+ globalConfettiConfigRef.current = globalConfettiConfig;
580
+ }, [globalConfettiConfig]);
581
+ React.useEffect(() => {
582
+ if (ui.enableConfetti === false) {
583
+ if (confettiTimerRef.current) {
584
+ clearTimeout(confettiTimerRef.current);
585
+ confettiTimerRef.current = null;
586
+ }
587
+ setShowConfetti(false);
588
+ setActiveConfettiConfig(null);
589
+ }
590
+ }, [ui.enableConfetti]);
561
591
  React.useEffect(() => {
562
592
  const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
593
+ var _a;
563
594
  if (seenAchievementsRef.current.has(event.achievementId)) {
564
595
  return;
565
596
  }
@@ -579,15 +610,19 @@ const AchievementEffects = ({ icons, ui }) => {
579
610
  return [...currentNotifications, unlockedAchievement];
580
611
  });
581
612
  }
582
- if (ui.enableConfetti !== false) {
613
+ const rewardConfetti = achievementConfettiRef.current.get(event.achievementId);
614
+ if (ui.enableConfetti !== false && rewardConfetti !== false) {
583
615
  if (confettiTimerRef.current) {
584
616
  clearTimeout(confettiTimerRef.current);
585
617
  }
618
+ const resolvedConfettiConfig = Object.assign(Object.assign({}, globalConfettiConfigRef.current), (rewardConfetti || {}));
619
+ const resolvedConfettiDuration = (_a = resolvedConfettiConfig.duration) !== null && _a !== void 0 ? _a : CONFETTI_DURATION_MS;
620
+ setActiveConfettiConfig(resolvedConfettiConfig);
586
621
  setShowConfetti(true);
587
622
  confettiTimerRef.current = setTimeout(() => {
588
623
  setShowConfetti(false);
589
624
  confettiTimerRef.current = null;
590
- }, CONFETTI_DURATION_MS);
625
+ }, resolvedConfettiDuration);
591
626
  }
592
627
  });
593
628
  const unsubscribeStateChanged = engine.on('state:changed', () => {
@@ -611,18 +646,19 @@ const AchievementEffects = ({ icons, ui }) => {
611
646
  return (React.createElement(React.Fragment, null,
612
647
  ui.enableNotifications !== false &&
613
648
  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 }))),
614
- ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: CONFETTI_DURATION_MS }))));
649
+ ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, Object.assign({ show: showConfetti }, renderedConfettiConfig, { duration: renderedConfettiDuration })))));
615
650
  };
616
651
  const AchievementProvider = (_a) => {
617
652
  var { children, icons = {}, ui = {}, useBuiltInUI } = _a, providerProps = __rest(_a, ["children", "icons", "ui", "useBuiltInUI"]);
618
653
  if (useBuiltInUI !== undefined) {
619
654
  warnDeprecation('`useBuiltInUI` is deprecated and is now a no-op because built-in UI is the default. It will be removed in 5.0.');
620
655
  }
656
+ const [{ achievements: preparedAchievements, confettiByAchievementId }] = React.useState(() => prepareAchievementsForConfetti(providerProps.achievements));
621
657
  const uiContextValue = React.useMemo(() => ({ icons, ui }), [icons, ui]);
622
658
  return (React.createElement(AchievementUIContext.Provider, { value: uiContextValue },
623
- React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { icons: icons }),
659
+ React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { achievements: preparedAchievements, icons: icons }),
624
660
  children,
625
- React.createElement(AchievementEffects, { icons: icons, ui: ui }))));
661
+ React.createElement(AchievementEffects, { achievementConfetti: confettiByAchievementId, icons: icons, ui: ui }))));
626
662
  };
627
663
 
628
664
  const useAchievements = () => {
@@ -1398,6 +1434,46 @@ const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'mode
1398
1434
  })))))));
1399
1435
  };
1400
1436
 
1437
+ /**
1438
+ * Hook to track window dimensions
1439
+ * Replacement for react-use's useWindowSize
1440
+ *
1441
+ * @returns Object with width and height properties
1442
+ *
1443
+ * @example
1444
+ * ```tsx
1445
+ * const { width, height } = useWindowSize();
1446
+ * console.log(`Window size: ${width}x${height}`);
1447
+ * ```
1448
+ */
1449
+ function useWindowSize() {
1450
+ const [size, setSize] = React.useState({
1451
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
1452
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
1453
+ });
1454
+ React.useEffect(() => {
1455
+ // Handle SSR - window may not be defined
1456
+ if (typeof window === 'undefined') {
1457
+ return;
1458
+ }
1459
+ const handleResize = () => {
1460
+ setSize({
1461
+ width: window.innerWidth,
1462
+ height: window.innerHeight,
1463
+ });
1464
+ };
1465
+ // Set initial size
1466
+ handleResize();
1467
+ // Add event listener
1468
+ window.addEventListener('resize', handleResize);
1469
+ // Cleanup
1470
+ return () => {
1471
+ window.removeEventListener('resize', handleResize);
1472
+ };
1473
+ }, []);
1474
+ return size;
1475
+ }
1476
+
1401
1477
  Object.defineProperty(exports, 'AchievementBuilder', {
1402
1478
  enumerable: true,
1403
1479
  get: function () { return achievementsEngine.AchievementBuilder; }