react-achievements 2.1.0 → 2.2.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.
Files changed (38) hide show
  1. package/README.md +183 -338
  2. package/dist/defaultStyles.d.ts +0 -9
  3. package/dist/hooks/useAchievement.d.ts +1 -1
  4. package/dist/index.cjs.js +290 -262
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.esm.js +293 -265
  7. package/dist/providers/AchievementProvider.d.ts +5 -4
  8. package/dist/redux/achievementSlice.d.ts +5 -6
  9. package/dist/types.d.ts +8 -0
  10. package/package.json +18 -9
  11. package/rollup.config.mjs +11 -0
  12. package/src/defaultStyles.ts +0 -52
  13. package/src/hooks/useAchievement.ts +8 -11
  14. package/src/index.ts +14 -8
  15. package/src/providers/AchievementProvider.tsx +147 -142
  16. package/src/redux/achievementSlice.ts +68 -45
  17. package/src/redux/notificationSlice.ts +5 -5
  18. package/src/redux/store.ts +1 -5
  19. package/src/types.ts +12 -7
  20. package/tsconfig.json +3 -1
  21. package/demo/README.md +0 -8
  22. package/demo/eslint.config.js +0 -38
  23. package/demo/index.html +0 -13
  24. package/demo/package-lock.json +0 -12053
  25. package/demo/package.json +0 -47
  26. package/demo/public/vite.svg +0 -1
  27. package/demo/src/AchievementConfig.ts +0 -37
  28. package/demo/src/App.css +0 -42
  29. package/demo/src/App.jsx +0 -89
  30. package/demo/src/assets/achievements/explorer.webp +0 -0
  31. package/demo/src/assets/achievements/seaoned_warrior.webp +0 -0
  32. package/demo/src/assets/achievements/warrior.webp +0 -0
  33. package/demo/src/assets/react.svg +0 -1
  34. package/demo/src/index.css +0 -68
  35. package/demo/src/main.jsx +0 -10
  36. package/demo/vite.config.js +0 -7
  37. package/src/components/AchievementModal.tsx +0 -57
  38. package/src/hooks/useAchievementState.ts +0 -12
@@ -1,11 +1,12 @@
1
1
  import React from 'react';
2
- import { AchievementProviderProps, AchievementMetrics as AchievementMetricsType } from '../types';
2
+ import 'react-toastify/dist/ReactToastify.css';
3
+ import { AchievementProviderProps, AchievementMetrics } from '../types';
3
4
  export interface AchievementContextType {
4
- updateMetrics: (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => void;
5
+ updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
5
6
  unlockedAchievements: string[];
6
7
  resetStorage: () => void;
7
8
  }
8
9
  export declare const AchievementContext: React.Context<AchievementContextType | undefined>;
9
10
  export declare const useAchievementContext: () => AchievementContextType;
10
- export declare const AchievementProvider: React.FC<AchievementProviderProps>;
11
- export declare function mergeDeep(target: any, source: any): any;
11
+ declare const AchievementProvider: React.FC<AchievementProviderProps>;
12
+ export { AchievementProvider };
@@ -1,7 +1,7 @@
1
1
  import { PayloadAction } from '@reduxjs/toolkit';
2
- import { AchievementConfiguration, InitialAchievementMetrics, AchievementMetrics } from '../types';
2
+ import { InitialAchievementMetrics, AchievementMetrics, SerializedAchievementConfiguration } from '../types';
3
3
  export interface AchievementState {
4
- config: AchievementConfiguration;
4
+ config: SerializedAchievementConfiguration;
5
5
  metrics: AchievementMetrics;
6
6
  unlockedAchievements: string[];
7
7
  previouslyAwardedAchievements: string[];
@@ -9,23 +9,22 @@ export interface AchievementState {
9
9
  }
10
10
  export declare const achievementSlice: import("@reduxjs/toolkit").Slice<AchievementState, {
11
11
  initialize: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<{
12
- config: AchievementConfiguration;
12
+ config: SerializedAchievementConfiguration;
13
13
  initialState?: InitialAchievementMetrics & {
14
14
  previouslyAwardedAchievements?: string[];
15
15
  };
16
16
  storageKey: string;
17
17
  }>) => void;
18
18
  setMetrics: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<AchievementMetrics>) => void;
19
- unlockAchievement: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
20
19
  markAchievementAsAwarded: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
21
20
  resetAchievements: (state: import("immer/dist/internal").WritableDraft<AchievementState>) => void;
22
21
  }, "achievements">;
23
22
  export declare const initialize: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
24
- config: AchievementConfiguration;
23
+ config: SerializedAchievementConfiguration;
25
24
  initialState?: InitialAchievementMetrics & {
26
25
  previouslyAwardedAchievements?: string[];
27
26
  };
28
27
  storageKey: string;
29
- }, "achievements/initialize">, setMetrics: import("@reduxjs/toolkit").ActionCreatorWithPayload<AchievementMetrics, "achievements/setMetrics">, unlockAchievement: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "achievements/unlockAchievement">, resetAchievements: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"achievements/resetAchievements">, markAchievementAsAwarded: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "achievements/markAchievementAsAwarded">;
28
+ }, "achievements/initialize">, setMetrics: import("@reduxjs/toolkit").ActionCreatorWithPayload<AchievementMetrics, "achievements/setMetrics">, resetAchievements: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"achievements/resetAchievements">, markAchievementAsAwarded: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "achievements/markAchievementAsAwarded">;
30
29
  declare const _default: import("@reduxjs/toolkit").Reducer<AchievementState>;
31
30
  export default _default;
package/dist/types.d.ts CHANGED
@@ -27,3 +27,11 @@ export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
27
27
  isConditionMet: (value: T) => boolean;
28
28
  achievementDetails: AchievementDetails;
29
29
  }
30
+ export interface SerializedAchievementUnlockCondition {
31
+ achievementDetails: AchievementDetails;
32
+ conditionType: 'number' | 'string' | 'boolean' | 'date';
33
+ conditionValue: any;
34
+ }
35
+ export interface SerializedAchievementConfiguration {
36
+ [metricName: string]: SerializedAchievementUnlockCondition[];
37
+ }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "react-achievements",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "This package allows users to transpose a React achievements engine over their React apps",
5
5
  "keywords": [
6
6
  "react",
7
7
  "badge",
8
8
  "achievement"
9
9
  ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/dave-b-b/react-achievements.git"
13
+ },
10
14
  "main": "dist/cjs.js",
11
15
  "module": "dist/index.esm.js",
12
16
  "types": "dist/index.d.ts",
@@ -26,6 +30,7 @@
26
30
  "@mui/icons-material": "^6.4.8",
27
31
  "@rollup/plugin-commonjs": "^26.0.1",
28
32
  "@rollup/plugin-node-resolve": "^15.2.3",
33
+ "@rollup/plugin-replace": "^6.0.2",
29
34
  "@storybook/addon-essentials": "^8.6.8",
30
35
  "@storybook/addon-interactions": "^8.6.8",
31
36
  "@storybook/addon-links": "^8.6.8",
@@ -35,23 +40,27 @@
35
40
  "@storybook/react": "^8.6.8",
36
41
  "@storybook/react-webpack5": "^8.6.8",
37
42
  "@storybook/test": "^8.6.8",
38
- "rollup": "^4.19.0",
39
- "rollup-plugin-typescript2": "^0.36.0",
40
- "storybook": "^8.6.8",
41
- "typescript": "^5.5.4",
42
43
  "@types/jest": "^29.5.12",
43
44
  "@types/node": "^20.14.12",
44
45
  "@types/react": "^18.3.3",
45
- "@types/react-dom": "^18.3.0"
46
+ "@types/react-dom": "^18.3.0",
47
+ "postcss": "^8.5.3",
48
+ "rollup": "^4.19.0",
49
+ "rollup-plugin-postcss": "^4.0.2",
50
+ "rollup-plugin-typescript2": "^0.36.0",
51
+ "storybook": "^8.6.8",
52
+ "typescript": "^5.5.4"
46
53
  },
47
54
  "peerDependencies": {
55
+ "@reduxjs/toolkit": "^1.0.0",
48
56
  "react": "^18.0.0",
57
+ "react-confetti": "^6.0.0",
49
58
  "react-dom": "^18.0.0",
50
59
  "react-redux": "^8.0.0 || ^9.0.0",
51
- "@reduxjs/toolkit": "^1.0.0",
52
- "react-confetti": "^6.0.0",
60
+ "react-toastify": "^10.0.0",
53
61
  "react-use": "^17.0.0"
54
62
  },
55
63
  "dependencies": {
64
+ "react-toastify": "^10.0.0"
56
65
  }
57
- }
66
+ }
package/rollup.config.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import resolve from '@rollup/plugin-node-resolve';
2
2
  import commonjs from '@rollup/plugin-commonjs';
3
3
  import typescript from 'rollup-plugin-typescript2';
4
+ import replace from '@rollup/plugin-replace';
5
+ import postcss from 'rollup-plugin-postcss';
4
6
 
5
7
  export default {
6
8
  input: 'src/index.ts',
@@ -15,6 +17,15 @@ export default {
15
17
  }
16
18
  ],
17
19
  plugins: [
20
+ replace({
21
+ preventAssignment: true,
22
+ 'this': 'undefined',
23
+ }),
24
+ postcss({
25
+ extract: false,
26
+ inject: true,
27
+ modules: false
28
+ }),
18
29
  resolve(),
19
30
  commonjs(),
20
31
  typescript({ tsconfig: './tsconfig.json' })
@@ -1,14 +1,5 @@
1
1
  type StyleObject = { [key: string]: string | number };
2
2
 
3
- interface ModalStyles {
4
- overlay: StyleObject;
5
- content: StyleObject;
6
- title: StyleObject;
7
- icon: StyleObject;
8
- description: StyleObject;
9
- button: StyleObject;
10
- }
11
-
12
3
  interface BadgesModalStyles {
13
4
  overlay: StyleObject;
14
5
  content: StyleObject;
@@ -21,54 +12,11 @@ interface BadgesModalStyles {
21
12
  }
22
13
 
23
14
  export interface Styles {
24
- achievementModal: ModalStyles;
25
15
  badgesModal: BadgesModalStyles;
26
16
  badgesButton: StyleObject;
27
17
  }
28
18
 
29
19
  export const defaultStyles: Styles = {
30
- achievementModal: {
31
- overlay: {
32
- position: 'fixed',
33
- top: 0,
34
- left: 0,
35
- right: 0,
36
- bottom: 0,
37
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
38
- display: 'flex',
39
- alignItems: 'center',
40
- justifyContent: 'center',
41
- },
42
- content: {
43
- backgroundColor: '#ffffff',
44
- borderRadius: '8px',
45
- padding: '20px',
46
- maxWidth: '400px',
47
- width: '100%',
48
- },
49
- title: {
50
- fontSize: '24px',
51
- fontWeight: 'bold',
52
- marginBottom: '10px',
53
- },
54
- icon: {
55
- width: '50px',
56
- height: '50px',
57
- marginBottom: '10px',
58
- },
59
- description: {
60
- fontSize: '16px',
61
- marginBottom: '20px',
62
- },
63
- button: {
64
- backgroundColor: '#007bff',
65
- color: '#ffffff',
66
- padding: '10px 20px',
67
- borderRadius: '4px',
68
- border: 'none',
69
- cursor: 'pointer',
70
- },
71
- },
72
20
  badgesModal: {
73
21
  overlay: {
74
22
  position: 'fixed',
@@ -1,20 +1,17 @@
1
- import { useSelector, useDispatch } from 'react-redux';
2
- import { RootState, AppDispatch } from '../redux/store';
1
+ import { useSelector } from 'react-redux';
2
+ import { RootState } from '../redux/store';
3
3
  import { useAchievementContext } from '../providers/AchievementProvider';
4
4
 
5
5
  export const useAchievement = () => {
6
- const dispatch: AppDispatch = useDispatch();
7
- const { updateMetrics, unlockedAchievements, resetStorage } = useAchievementContext() || {};
6
+ const { updateMetrics, unlockedAchievements, resetStorage } = useAchievementContext();
8
7
  const metrics = useSelector((state: RootState) => state.achievements.metrics);
9
- const notifications = useSelector((state: RootState) => state.notifications.notifications);
10
8
  const config = useSelector((state: RootState) => state.achievements.config);
11
9
 
12
10
  return {
13
- metrics: metrics,
14
- unlockedAchievements: unlockedAchievements || [],
15
- notifications: notifications,
16
- config: config,
17
- updateMetrics: updateMetrics || (() => {}),
18
- resetStorage: resetStorage || (() => {}),
11
+ metrics,
12
+ unlockedAchievements,
13
+ config,
14
+ updateMetrics,
15
+ resetStorage,
19
16
  };
20
17
  };
package/src/index.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  import { AchievementProvider, useAchievementContext as useAchievement } from './providers/AchievementProvider';
2
- import { AchievementMetrics, AchievementConfiguration, AchievementDetails, AchievementUnlockCondition } from './types';
3
- import ConfettiWrapper from './components/ConfettiWrapper';
2
+ import type {
3
+ AchievementMetrics,
4
+ AchievementConfiguration,
5
+ AchievementDetails,
6
+ AchievementUnlockCondition,
7
+ AchievementMetricValue,
8
+ InitialAchievementMetrics
9
+ } from './types';
4
10
  import achievementReducer from './redux/achievementSlice';
5
- import notificationReducer from './redux/notificationSlice'
6
- import { useAchievementState } from './hooks/useAchievementState';
7
11
 
8
12
  export {
9
13
  AchievementProvider,
10
14
  useAchievement,
15
+ achievementReducer,
16
+ };
17
+
18
+ export type {
11
19
  AchievementMetrics,
12
20
  AchievementConfiguration,
13
21
  AchievementDetails,
14
22
  AchievementUnlockCondition,
15
- ConfettiWrapper,
16
- achievementReducer,
17
- notificationReducer,
18
- useAchievementState,
23
+ AchievementMetricValue,
24
+ InitialAchievementMetrics,
19
25
  };
@@ -1,24 +1,24 @@
1
- import React, { useEffect, useCallback } from 'react';
1
+ import React, { useEffect, useCallback, useState, useMemo, useRef } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import { RootState, AppDispatch } from '../redux/store';
4
- import { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } from '../redux/achievementSlice';
5
- import { addNotification, clearNotifications } from '../redux/notificationSlice';
4
+ import { initialize, setMetrics, resetAchievements, unlockAchievement, clearNotifications } from '../redux/achievementSlice';
5
+ import { toast, ToastContainer } from 'react-toastify';
6
+ import 'react-toastify/dist/ReactToastify.css';
6
7
  import {
7
8
  AchievementDetails,
8
9
  AchievementMetricValue,
9
- AchievementUnlockCondition,
10
+ AchievementConfiguration,
10
11
  AchievementProviderProps,
11
- AchievementMetrics as AchievementMetricsType,
12
+ AchievementMetrics,
13
+ AchievementState
12
14
  } from '../types';
13
- import { defaultStyles, Styles } from '../defaultStyles';
14
- import AchievementModal from '../components/AchievementModal';
15
- import BadgesModal from '../components/BadgesModal';
16
15
  import BadgesButton from '../components/BadgesButton';
16
+ import BadgesModal from '../components/BadgesModal';
17
17
  import ConfettiWrapper from '../components/ConfettiWrapper';
18
- import { defaultAchievementIcons } from '../assets/defaultIcons';
18
+ import { defaultStyles } from '../defaultStyles';
19
19
 
20
20
  export interface AchievementContextType {
21
- updateMetrics: (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => void;
21
+ updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
22
22
  unlockedAchievements: string[];
23
23
  resetStorage: () => void;
24
24
  }
@@ -33,165 +33,170 @@ export const useAchievementContext = () => {
33
33
  return context;
34
34
  };
35
35
 
36
- export const AchievementProvider: React.FC<AchievementProviderProps> = ({
37
- children,
38
- config,
39
- initialState = {},
40
- storageKey = 'react-achievements',
41
- badgesButtonPosition = 'top-right',
42
- styles = {},
43
- icons = {},
44
- }: AchievementProviderProps) => {
36
+ // Helper function to serialize dates for Redux actions
37
+ const serializeMetrics = (metrics: AchievementMetrics): AchievementMetrics => {
38
+ return Object.entries(metrics).reduce((acc, [key, values]) => ({
39
+ ...acc,
40
+ [key]: values.map(value => value instanceof Date ? value.toISOString() : value)
41
+ }), {} as AchievementMetrics);
42
+ };
43
+
44
+ // Helper function to parse potential date strings
45
+ const deserializeValue = (value: string | number | boolean): AchievementMetricValue => {
46
+ if (typeof value === 'string') {
47
+ // Try to parse ISO date string
48
+ const date = new Date(value);
49
+ if (!isNaN(date.getTime()) && value === date.toISOString()) {
50
+ return date;
51
+ }
52
+ }
53
+ return value;
54
+ };
55
+
56
+ const AchievementProvider: React.FC<AchievementProviderProps> = ({
57
+ children,
58
+ config,
59
+ initialState = {},
60
+ storageKey = 'react-achievements',
61
+ badgesButtonPosition = 'top-right',
62
+ styles = {},
63
+ icons = {},
64
+ }) => {
45
65
  const dispatch: AppDispatch = useDispatch();
66
+ const configRef = useRef(config);
46
67
  const metrics = useSelector((state: RootState) => state.achievements.metrics);
47
68
  const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
48
- const previouslyAwardedAchievementIds = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
49
- const notifications = useSelector((state: RootState) => state.notifications.notifications);
50
- const mergedStyles = React.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
51
-
52
- const [currentAchievement, setCurrentAchievement] = React.useState<AchievementDetails | null>(null);
53
- const [showBadges, setShowBadges] = React.useState(false);
54
- const [showConfetti, setShowConfetti] = React.useState(false);
55
-
56
- const mergedIcons = React.useMemo(() => ({ ...defaultAchievementIcons, ...icons }), [icons]);
57
-
58
- const updateMetrics = useCallback(
59
- (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => {
60
- dispatch(setMetrics(typeof newMetrics === 'function' ? newMetrics(metrics) : newMetrics));
61
- },
62
- [dispatch, metrics]
63
- );
64
-
65
- const resetStorage = useCallback(() => {
66
- localStorage.removeItem(storageKey);
67
- dispatch(resetAchievements());
68
- }, [dispatch, storageKey]);
69
+ const pendingNotifications = useSelector((state: RootState) => state.achievements.pendingNotifications);
70
+ const [showBadges, setShowBadges] = useState(false);
71
+ const [showConfetti, setShowConfetti] = useState(false);
69
72
 
73
+ // Update config ref when it changes
70
74
  useEffect(() => {
71
- dispatch(initialize({ config, initialState, storageKey }));
72
- }, [dispatch, config, initialState, storageKey]);
75
+ configRef.current = config;
76
+ }, [config]);
73
77
 
74
78
  const checkAchievements = useCallback(() => {
75
- const newAchievementsToAward: AchievementDetails[] = [];
76
-
77
- if (!unlockedAchievementIds) {
78
- console.error('unlockedAchievements is undefined!');
79
- return;
80
- }
81
-
82
- Object.entries(config)
83
- .forEach(([metricName, conditions]) => {
84
- const metricValues = metrics[metricName];
85
-
86
- if (!metricValues) {
87
- return;
79
+ Object.entries(configRef.current).forEach(([metricName, conditions]) => {
80
+ const metricValues = metrics[metricName];
81
+ if (!metricValues) return;
82
+
83
+ const latestValue = deserializeValue(metricValues[metricValues.length - 1]);
84
+ const state: AchievementState = {
85
+ metrics: Object.entries(metrics).reduce((acc, [key, values]) => ({
86
+ ...acc,
87
+ [key]: values.map(deserializeValue)
88
+ }), {}),
89
+ unlockedAchievements: unlockedAchievementIds
90
+ };
91
+
92
+ conditions.forEach((condition) => {
93
+ if (
94
+ condition.isConditionMet(latestValue, state) &&
95
+ !unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
96
+ ) {
97
+ dispatch(unlockAchievement(condition.achievementDetails));
98
+ setShowConfetti(true);
88
99
  }
89
-
90
- conditions
91
- .filter(condition => !previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId))
92
- .forEach((condition) => {
93
- if (
94
- metricValues.some((value: AchievementMetricValue) => condition.isConditionMet(value)) &&
95
- !unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
96
- ) {
97
- dispatch(unlockAchievement(condition.achievementDetails.achievementId));
98
- newAchievementsToAward.push(condition.achievementDetails);
99
- } else if (
100
- metricValues.some((value: AchievementMetricValue) => condition.isConditionMet(value)) &&
101
- unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
102
- !previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId)
103
- ) {
104
- newAchievementsToAward.push(condition.achievementDetails);
105
- }
106
- });
107
100
  });
101
+ });
102
+ }, [metrics, unlockedAchievementIds, dispatch]);
108
103
 
109
- if (newAchievementsToAward.length > 0) {
110
- newAchievementsToAward.forEach((achievement) => {
111
- dispatch(addNotification(achievement));
112
- dispatch(markAchievementAsAwarded(achievement.achievementId));
104
+ // Handle notifications
105
+ useEffect(() => {
106
+ if (pendingNotifications.length > 0) {
107
+ pendingNotifications.forEach((notification) => {
108
+ toast.success(
109
+ <div>
110
+ <h4 style={{ margin: '0 0 8px 0' }}>Achievement Unlocked! 🎉</h4>
111
+ <strong>{notification.achievementTitle}</strong>
112
+ <p style={{ margin: '4px 0 0 0' }}>{notification.achievementDescription}</p>
113
+ {notification.achievementIconKey && icons[notification.achievementIconKey] && (
114
+ <div style={{ fontSize: '24px', marginTop: '8px' }}>
115
+ {icons[notification.achievementIconKey]}
116
+ </div>
117
+ )}
118
+ </div>,
119
+ {
120
+ position: "top-right",
121
+ autoClose: 5000,
122
+ hideProgressBar: false,
123
+ closeOnClick: true,
124
+ pauseOnHover: true,
125
+ draggable: true,
126
+ }
127
+ );
113
128
  });
114
- setShowConfetti(true);
129
+ dispatch(clearNotifications());
115
130
  }
116
- }, [config, metrics, unlockedAchievementIds, previouslyAwardedAchievementIds, dispatch]);
131
+ }, [pendingNotifications, dispatch, icons]);
117
132
 
133
+ // Reset confetti after delay
134
+ useEffect(() => {
135
+ if (showConfetti) {
136
+ const timer = setTimeout(() => setShowConfetti(false), 5000);
137
+ return () => clearTimeout(timer);
138
+ }
139
+ }, [showConfetti]);
140
+
141
+ // Check for achievements when metrics change
118
142
  useEffect(() => {
119
143
  checkAchievements();
120
144
  }, [metrics, checkAchievements]);
121
145
 
146
+ // Initialize on mount, but don't store config in Redux
122
147
  useEffect(() => {
123
- if (notifications.length > 0 && !currentAchievement) {
124
- setCurrentAchievement(notifications[0]);
125
- }
126
- }, [notifications, currentAchievement]);
127
-
128
- const showBadgesModal = () => setShowBadges(true);
129
-
130
- const getAchievements = useCallback(
131
- (achievedIds: string[]) => {
132
- return Object.values(config).flatMap((conditions) =>
133
- conditions
134
- .filter((c) => achievedIds.includes(c.achievementDetails.achievementId))
135
- .map((c) => c.achievementDetails)
136
- );
137
- },
138
- [config]
139
- );
140
-
141
- const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
142
- const previouslyAwardedAchievementsDetails = getAchievements(previouslyAwardedAchievementIds);
148
+ dispatch(initialize({
149
+ initialState,
150
+ storageKey,
151
+ }));
152
+ }, [dispatch, initialState, storageKey]);
153
+
154
+ // Convert achievement IDs to details using config from ref
155
+ const achievementDetails = useMemo(() => {
156
+ return unlockedAchievementIds
157
+ .map(id => {
158
+ const achievement = Object.values(configRef.current)
159
+ .flat()
160
+ .find(condition => condition.achievementDetails.achievementId === id);
161
+ return achievement?.achievementDetails;
162
+ })
163
+ .filter((a): a is AchievementDetails => !!a);
164
+ }, [unlockedAchievementIds]);
143
165
 
144
166
  return (
145
- <AchievementContext.Provider value={{ updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage }}>
167
+ <AchievementContext.Provider value={{
168
+ updateMetrics: (newMetrics) => {
169
+ if (typeof newMetrics === 'function') {
170
+ const updatedMetrics = newMetrics(metrics);
171
+ dispatch(setMetrics(serializeMetrics(updatedMetrics)));
172
+ } else {
173
+ dispatch(setMetrics(serializeMetrics(newMetrics)));
174
+ }
175
+ },
176
+ unlockedAchievements: unlockedAchievementIds,
177
+ resetStorage: () => {
178
+ localStorage.removeItem(storageKey);
179
+ dispatch(resetAchievements());
180
+ },
181
+ }}>
146
182
  {children}
147
- <AchievementModal
148
- isOpen={!!currentAchievement}
149
- achievement={currentAchievement}
150
- onClose={() => {
151
- setCurrentAchievement(null);
152
- if (currentAchievement) {
153
- dispatch(clearNotifications());
154
- }
155
- }}
156
- styles={mergedStyles.achievementModal}
157
- icons={mergedIcons}
183
+ <ToastContainer />
184
+ <ConfettiWrapper show={showConfetti} />
185
+ <BadgesButton
186
+ onClick={() => setShowBadges(true)}
187
+ position={badgesButtonPosition}
188
+ styles={styles.badgesButton || defaultStyles.badgesButton}
189
+ unlockedAchievements={achievementDetails}
158
190
  />
159
191
  <BadgesModal
160
192
  isOpen={showBadges}
161
- achievements={previouslyAwardedAchievementsDetails}
193
+ achievements={achievementDetails}
162
194
  onClose={() => setShowBadges(false)}
163
- styles={mergedStyles.badgesModal}
164
- icons={mergedIcons}
195
+ styles={styles.badgesModal || defaultStyles.badgesModal}
196
+ icons={icons}
165
197
  />
166
- <BadgesButton
167
- onClick={showBadgesModal}
168
- position={badgesButtonPosition}
169
- styles={mergedStyles.badgesButton}
170
- unlockedAchievements={previouslyAwardedAchievementsDetails}
171
- />
172
- <ConfettiWrapper show={showConfetti || notifications.length > 0} />
173
198
  </AchievementContext.Provider>
174
199
  );
175
200
  };
176
201
 
177
- function isObject(item: any) {
178
- return item && typeof item === 'object' && !Array.isArray(item);
179
- }
180
-
181
- export function mergeDeep(target: any, source: any) {
182
- const output = { ...target };
183
- if (isObject(target) && isObject(source)) {
184
- Object.keys(source).forEach((key) => {
185
- if (isObject(source[key])) {
186
- if (!(key in target)) {
187
- output[key] = source[key];
188
- } else {
189
- output[key] = mergeDeep(target[key], source[key]);
190
- }
191
- } else {
192
- output[key] = source[key];
193
- }
194
- });
195
- }
196
- return output;
197
- }
202
+ export { AchievementProvider };