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/index.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
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
  },
@@ -436,129 +437,159 @@ const BuiltInNotification = ({ achievement, onClose, duration = 5000, position =
436
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")));
437
438
  };
438
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));
439
450
  /**
440
- * Hook to track window dimensions
441
- * Replacement for react-use's useWindowSize
442
- *
443
- * @returns Object with width and height properties
444
- *
445
- * @example
446
- * ```tsx
447
- * const { width, height } = useWindowSize();
448
- * console.log(`Window size: ${width}x${height}`);
449
- * ```
451
+ * Built-in confetti component
452
+ * Canvas-based confetti animation powered by canvas-confetti.
450
453
  */
451
- function useWindowSize() {
452
- const [size, setSize] = useState({
453
- width: typeof window !== 'undefined' ? window.innerWidth : 0,
454
- height: typeof window !== 'undefined' ? window.innerHeight : 0,
455
- });
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);
456
456
  useEffect(() => {
457
- // Handle SSR - window may not be defined
458
- if (typeof window === 'undefined') {
457
+ if (!show) {
458
+ setIsVisible(false);
459
+ confetti.reset();
459
460
  return;
460
461
  }
461
- const handleResize = () => {
462
- setSize({
463
- width: window.innerWidth,
464
- height: window.innerHeight,
465
- });
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,
466
475
  };
467
- // Set initial size
468
- handleResize();
469
- // Add event listener
470
- window.addEventListener('resize', handleResize);
471
- // Cleanup
472
- return () => {
473
- window.removeEventListener('resize', handleResize);
474
- };
475
- }, []);
476
- return size;
477
- }
478
-
479
- /**
480
- * Built-in confetti component
481
- * Lightweight CSS-based confetti animation
482
- */
483
- const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'], }) => {
484
- const [isVisible, setIsVisible] = useState(false);
485
- const { width, height } = useWindowSize();
486
- useEffect(() => {
487
- if (show) {
488
- setIsVisible(true);
489
- const timer = setTimeout(() => setIsVisible(false), duration);
490
- 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 } }));
491
483
  }
492
- else {
484
+ const timer = setTimeout(() => {
493
485
  setIsVisible(false);
494
- }
495
- }, [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
+ ]);
496
504
  if (!isVisible)
497
505
  return null;
498
- const containerStyles = {
499
- position: 'fixed',
500
- top: 0,
501
- left: 0,
502
- width: '100%',
503
- height: '100%',
504
- pointerEvents: 'none',
505
- zIndex: 10001,
506
- overflow: 'hidden',
507
- };
508
- // Generate particles
509
- const particles = Array.from({ length: particleCount }, (_, i) => {
510
- const color = colors[Math.floor(Math.random() * colors.length)];
511
- const startX = Math.random() * width;
512
- const rotation = Math.random() * 360;
513
- const fallDuration = 3 + Math.random() * 2; // 3-5 seconds
514
- const delay = Math.random() * 0.5; // 0-0.5s delay
515
- const shape = Math.random() > 0.5 ? 'circle' : 'square';
516
- const particleStyles = {
517
- position: 'absolute',
518
- top: '-20px',
519
- left: `${startX}px`,
520
- width: '10px',
521
- height: '10px',
522
- backgroundColor: color,
523
- borderRadius: shape === 'circle' ? '50%' : '0',
524
- transform: `rotate(${rotation}deg)`,
525
- animation: `confettiFall ${fallDuration}s linear ${delay}s forwards`,
526
- opacity: 0.9,
527
- };
528
- return React.createElement("div", { key: i, style: particleStyles, "data-testid": "confetti-particle" });
529
- });
530
- return (React.createElement(React.Fragment, null,
531
- React.createElement("style", null, `
532
- @keyframes confettiFall {
533
- 0% {
534
- transform: translateY(0) rotate(0deg);
535
- opacity: 1;
536
- }
537
- 100% {
538
- transform: translateY(${height + 50}px) rotate(720deg);
539
- opacity: 0;
540
- }
541
- }
542
- `),
543
- React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
506
+ return React.createElement("div", { "data-testid": "built-in-confetti", style: { display: 'none' } });
544
507
  };
545
508
 
546
509
  const DEFAULT_NOTIFICATION_DURATION_MS = 5000;
547
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);
520
+ }
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);
549
+ }
550
+ });
551
+ });
552
+ return { achievements: normalizedAchievements, confettiByAchievementId };
553
+ };
548
554
  const AchievementUIContext = createContext({
549
555
  icons: {},
550
556
  ui: {},
551
557
  });
552
- const AchievementEffects = ({ icons, ui }) => {
553
- var _a;
558
+ const AchievementEffects = ({ icons, ui, achievementConfetti }) => {
559
+ var _a, _b;
554
560
  const engine = useAchievementEngine();
555
561
  const seenAchievementsRef = useRef(new Set(engine.getUnlocked()));
556
562
  const confettiTimerRef = useRef(null);
563
+ const achievementConfettiRef = useRef(achievementConfetti);
557
564
  const [showConfetti, setShowConfetti] = useState(false);
565
+ const [activeConfettiConfig, setActiveConfettiConfig] = useState(null);
558
566
  const [notifications, setNotifications] = useState([]);
559
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]);
560
590
  useEffect(() => {
561
591
  const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
592
+ var _a;
562
593
  if (seenAchievementsRef.current.has(event.achievementId)) {
563
594
  return;
564
595
  }
@@ -578,15 +609,19 @@ const AchievementEffects = ({ icons, ui }) => {
578
609
  return [...currentNotifications, unlockedAchievement];
579
610
  });
580
611
  }
581
- if (ui.enableConfetti !== false) {
612
+ const rewardConfetti = achievementConfettiRef.current.get(event.achievementId);
613
+ if (ui.enableConfetti !== false && rewardConfetti !== false) {
582
614
  if (confettiTimerRef.current) {
583
615
  clearTimeout(confettiTimerRef.current);
584
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);
585
620
  setShowConfetti(true);
586
621
  confettiTimerRef.current = setTimeout(() => {
587
622
  setShowConfetti(false);
588
623
  confettiTimerRef.current = null;
589
- }, CONFETTI_DURATION_MS);
624
+ }, resolvedConfettiDuration);
590
625
  }
591
626
  });
592
627
  const unsubscribeStateChanged = engine.on('state:changed', () => {
@@ -610,18 +645,19 @@ const AchievementEffects = ({ icons, ui }) => {
610
645
  return (React.createElement(React.Fragment, null,
611
646
  ui.enableNotifications !== false &&
612
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 }))),
613
- ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: CONFETTI_DURATION_MS }))));
648
+ ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, Object.assign({ show: showConfetti }, renderedConfettiConfig, { duration: renderedConfettiDuration })))));
614
649
  };
615
650
  const AchievementProvider = (_a) => {
616
651
  var { children, icons = {}, ui = {}, useBuiltInUI } = _a, providerProps = __rest(_a, ["children", "icons", "ui", "useBuiltInUI"]);
617
652
  if (useBuiltInUI !== undefined) {
618
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.');
619
654
  }
655
+ const [{ achievements: preparedAchievements, confettiByAchievementId }] = useState(() => prepareAchievementsForConfetti(providerProps.achievements));
620
656
  const uiContextValue = useMemo(() => ({ icons, ui }), [icons, ui]);
621
657
  return (React.createElement(AchievementUIContext.Provider, { value: uiContextValue },
622
- React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { icons: icons }),
658
+ React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { achievements: preparedAchievements, icons: icons }),
623
659
  children,
624
- React.createElement(AchievementEffects, { icons: icons, ui: ui }))));
660
+ React.createElement(AchievementEffects, { achievementConfetti: confettiByAchievementId, icons: icons, ui: ui }))));
625
661
  };
626
662
 
627
663
  const useAchievements = () => {
@@ -1397,5 +1433,45 @@ const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'mode
1397
1433
  })))))));
1398
1434
  };
1399
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
+
1400
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 };
1401
1477
  //# sourceMappingURL=index.esm.js.map