react-achievements 2.1.1 → 2.2.1

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 +184 -339
  2. package/demo/src/AchievementConfig.ts +0 -37
  3. package/dist/defaultStyles.d.ts +0 -9
  4. package/dist/index.cjs.js +107 -117
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.esm.js +108 -118
  7. package/dist/providers/AchievementProvider.d.ts +1 -0
  8. package/dist/redux/achievementSlice.d.ts +1 -2
  9. package/package.json +11 -1
  10. package/rollup.config.mjs +6 -1
  11. package/src/defaultStyles.ts +0 -52
  12. package/src/hooks/useAchievement.ts +8 -11
  13. package/src/index.ts +14 -8
  14. package/src/providers/AchievementProvider.tsx +117 -136
  15. package/src/redux/achievementSlice.ts +65 -47
  16. package/src/redux/notificationSlice.ts +5 -5
  17. package/src/redux/store.ts +1 -5
  18. package/src/types.ts +12 -17
  19. package/tsconfig.json +3 -1
  20. package/demo/README.md +0 -8
  21. package/demo/eslint.config.js +0 -38
  22. package/demo/index.html +0 -13
  23. package/demo/package-lock.json +0 -12053
  24. package/demo/package.json +0 -47
  25. package/demo/public/vite.svg +0 -1
  26. package/demo/src/App.css +0 -42
  27. package/demo/src/App.jsx +0 -89
  28. package/demo/src/assets/achievements/explorer.webp +0 -0
  29. package/demo/src/assets/achievements/seaoned_warrior.webp +0 -0
  30. package/demo/src/assets/achievements/warrior.webp +0 -0
  31. package/demo/src/assets/react.svg +0 -1
  32. package/demo/src/index.css +0 -68
  33. package/demo/src/main.jsx +0 -10
  34. package/demo/vite.config.js +0 -7
  35. package/images/delete_local_storage.png +0 -0
  36. package/images/demo.gif +0 -0
  37. package/src/components/AchievementModal.tsx +0 -57
  38. package/src/hooks/useAchievementState.ts +0 -12
@@ -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,19 +1,17 @@
1
- import React, { useEffect, useCallback, useState, useMemo } 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
- SerializedAchievementUnlockCondition,
10
- SerializedAchievementConfiguration,
10
+ AchievementConfiguration,
11
11
  AchievementProviderProps,
12
12
  AchievementMetrics,
13
- AchievementConfiguration,
14
- AchievementUnlockCondition,
13
+ AchievementState
15
14
  } from '../types';
16
- import AchievementModal from '../components/AchievementModal';
17
15
  import BadgesButton from '../components/BadgesButton';
18
16
  import BadgesModal from '../components/BadgesModal';
19
17
  import ConfettiWrapper from '../components/ConfettiWrapper';
@@ -35,6 +33,26 @@ export const useAchievementContext = () => {
35
33
  return context;
36
34
  };
37
35
 
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
+
38
56
  const AchievementProvider: React.FC<AchievementProviderProps> = ({
39
57
  children,
40
58
  config,
@@ -45,171 +63,134 @@ const AchievementProvider: React.FC<AchievementProviderProps> = ({
45
63
  icons = {},
46
64
  }) => {
47
65
  const dispatch: AppDispatch = useDispatch();
66
+ const configRef = useRef(config);
48
67
  const metrics = useSelector((state: RootState) => state.achievements.metrics);
49
68
  const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
50
- const previouslyAwardedAchievements = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
51
- const notifications = useSelector((state: RootState) => state.notifications.notifications);
52
- const [currentAchievement, setCurrentAchievement] = useState<AchievementDetails | null>(null);
69
+ const pendingNotifications = useSelector((state: RootState) => state.achievements.pendingNotifications);
53
70
  const [showBadges, setShowBadges] = useState(false);
54
71
  const [showConfetti, setShowConfetti] = useState(false);
55
72
 
56
- const serializeConfig = (config: AchievementConfiguration): SerializedAchievementConfiguration => {
57
- const serializedConfig: SerializedAchievementConfiguration = {};
58
- Object.entries(config).forEach(([metricName, conditions]) => {
59
- serializedConfig[metricName] = (conditions as AchievementUnlockCondition<AchievementMetricValue>[]).map((condition: AchievementUnlockCondition<AchievementMetricValue>) => {
60
- // Analyze the isConditionMet function to determine type and value
61
- const funcString = condition.isConditionMet.toString();
62
- let conditionType: 'number' | 'string' | 'boolean' | 'date';
63
- let conditionValue: any;
64
-
65
- if (funcString.includes('typeof value === "number"') || funcString.includes('typeof value === \'number\'')) {
66
- conditionType = 'number';
67
- const matches = funcString.match(/value\s*>=?\s*(\d+)/);
68
- conditionValue = matches ? parseInt(matches[1]) : 0;
69
- } else if (funcString.includes('typeof value === "string"') || funcString.includes('typeof value === \'string\'')) {
70
- conditionType = 'string';
71
- const matches = funcString.match(/value\s*===?\s*['"](.+)['"]/);
72
- conditionValue = matches ? matches[1] : '';
73
- } else if (funcString.includes('typeof value === "boolean"') || funcString.includes('typeof value === \'boolean\'')) {
74
- conditionType = 'boolean';
75
- conditionValue = funcString.includes('=== true');
76
- } else if (funcString.includes('instanceof Date')) {
77
- conditionType = 'date';
78
- const matches = funcString.match(/new Date\(['"](.+)['"]\)/);
79
- conditionValue = matches ? matches[1] : new Date().toISOString();
80
- } else {
81
- // Default to number type if we can't determine the type
82
- conditionType = 'number';
83
- conditionValue = 1;
84
- }
85
-
86
- return {
87
- achievementDetails: condition.achievementDetails,
88
- conditionType,
89
- conditionValue,
90
- };
91
- });
92
- });
93
- return serializedConfig;
94
- };
95
-
96
- const serializedConfig = useMemo(() => serializeConfig(config), [config]);
73
+ // Update config ref when it changes
74
+ useEffect(() => {
75
+ configRef.current = config;
76
+ }, [config]);
97
77
 
98
78
  const checkAchievements = useCallback(() => {
99
- const newAchievements: AchievementDetails[] = [];
100
- Object.entries(serializedConfig).forEach(([metricName, conditions]) => {
79
+ Object.entries(configRef.current).forEach(([metricName, conditions]) => {
101
80
  const metricValues = metrics[metricName];
102
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
+
103
92
  conditions.forEach((condition) => {
104
- const isConditionMet = (value: AchievementMetricValue) => {
105
- switch (condition.conditionType) {
106
- case 'number':
107
- return typeof value === 'number' && value >= condition.conditionValue;
108
- case 'string':
109
- return typeof value === 'string' && value === condition.conditionValue;
110
- case 'boolean':
111
- return typeof value === 'boolean' && value === condition.conditionValue;
112
- case 'date':
113
- return value instanceof Date &&
114
- value.getTime() >= new Date(condition.conditionValue).getTime();
115
- default:
116
- return false;
117
- }
118
- };
119
93
  if (
120
- metricValues.some((value: AchievementMetricValue) => isConditionMet(value)) &&
121
- !unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
122
- !previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId)
94
+ condition.isConditionMet(latestValue, state) &&
95
+ !unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
123
96
  ) {
124
- newAchievements.push(condition.achievementDetails);
97
+ dispatch(unlockAchievement(condition.achievementDetails));
98
+ setShowConfetti(true);
125
99
  }
126
100
  });
127
101
  });
128
- if (newAchievements.length > 0) {
129
- newAchievements.forEach((achievement) => {
130
- dispatch(unlockAchievement(achievement.achievementId));
131
- dispatch(markAchievementAsAwarded(achievement.achievementId));
132
- dispatch(addNotification(achievement));
133
- });
134
- setShowConfetti(true);
135
- }
136
- }, [serializedConfig, metrics, unlockedAchievementIds, previouslyAwardedAchievements, dispatch]);
102
+ }, [metrics, unlockedAchievementIds, dispatch]);
137
103
 
104
+ // Handle notifications
138
105
  useEffect(() => {
139
- checkAchievements();
140
- }, [metrics, checkAchievements]);
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
+ );
128
+ });
129
+ dispatch(clearNotifications());
130
+ }
131
+ }, [pendingNotifications, dispatch, icons]);
141
132
 
133
+ // Reset confetti after delay
142
134
  useEffect(() => {
143
- if (notifications.length > 0 && !currentAchievement) {
144
- setCurrentAchievement(notifications[0]);
135
+ if (showConfetti) {
136
+ const timer = setTimeout(() => setShowConfetti(false), 5000);
137
+ return () => clearTimeout(timer);
145
138
  }
146
- }, [notifications, currentAchievement]);
139
+ }, [showConfetti]);
147
140
 
148
- const showBadgesModal = () => setShowBadges(true);
141
+ // Check for achievements when metrics change
142
+ useEffect(() => {
143
+ checkAchievements();
144
+ }, [metrics, checkAchievements]);
149
145
 
146
+ // Initialize on mount, but don't store config in Redux
150
147
  useEffect(() => {
151
148
  dispatch(initialize({
152
- config: serializedConfig,
153
149
  initialState,
154
150
  storageKey,
155
151
  }));
156
- }, [dispatch, serializedConfig, initialState, storageKey]);
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]);
157
165
 
158
166
  return (
159
- <AchievementContext.Provider
160
- value={{
161
- updateMetrics: (newMetrics) => {
162
- if (typeof newMetrics === 'function') {
163
- const currentMetrics = metrics;
164
- const updatedMetrics = newMetrics(currentMetrics);
165
- dispatch(setMetrics(updatedMetrics));
166
- } else {
167
- dispatch(setMetrics(newMetrics));
168
- }
169
- },
170
- unlockedAchievements: unlockedAchievementIds,
171
- resetStorage: () => {
172
- localStorage.removeItem(storageKey);
173
- dispatch(resetAchievements());
174
- },
175
- }}
176
- >
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
+ }}>
177
182
  {children}
183
+ <ToastContainer />
178
184
  <ConfettiWrapper show={showConfetti} />
179
- <AchievementModal
180
- isOpen={!!currentAchievement}
181
- achievement={currentAchievement}
182
- onClose={() => {
183
- setCurrentAchievement(null);
184
- dispatch(clearNotifications());
185
- setShowConfetti(false);
186
- }}
187
- styles={styles.achievementModal || defaultStyles.achievementModal}
188
- icons={icons}
189
- />
190
185
  <BadgesButton
191
- onClick={showBadgesModal}
186
+ onClick={() => setShowBadges(true)}
192
187
  position={badgesButtonPosition}
193
188
  styles={styles.badgesButton || defaultStyles.badgesButton}
194
- unlockedAchievements={[...unlockedAchievementIds, ...previouslyAwardedAchievements]
195
- .filter((id, index, self) => self.indexOf(id) === index) // Remove duplicates
196
- .map(id => {
197
- const achievement = Object.values(serializedConfig)
198
- .flat()
199
- .find(condition => condition.achievementDetails.achievementId === id);
200
- return achievement?.achievementDetails;
201
- }).filter((a): a is AchievementDetails => !!a)}
189
+ unlockedAchievements={achievementDetails}
202
190
  />
203
191
  <BadgesModal
204
192
  isOpen={showBadges}
205
- achievements={[...unlockedAchievementIds, ...previouslyAwardedAchievements]
206
- .filter((id, index, self) => self.indexOf(id) === index) // Remove duplicates
207
- .map(id => {
208
- const achievement = Object.values(serializedConfig)
209
- .flat()
210
- .find(condition => condition.achievementDetails.achievementId === id);
211
- return achievement?.achievementDetails;
212
- }).filter((a): a is AchievementDetails => !!a)}
193
+ achievements={achievementDetails}
213
194
  onClose={() => setShowBadges(false)}
214
195
  styles={styles.badgesModal || defaultStyles.badgesModal}
215
196
  icons={icons}
@@ -1,91 +1,109 @@
1
1
  // src/redux/achievementSlice.ts
2
2
  import { createSlice, PayloadAction } from '@reduxjs/toolkit';
3
3
  import {
4
- AchievementConfiguration,
5
4
  InitialAchievementMetrics,
6
5
  AchievementMetrics,
7
6
  AchievementDetails,
8
- SerializedAchievementConfiguration,
7
+ AchievementMetricValue,
9
8
  } from '../types';
10
9
 
10
+ // Helper function to serialize dates
11
+ const serializeValue = (value: AchievementMetricValue): string | number | boolean => {
12
+ if (value instanceof Date) {
13
+ return value.toISOString();
14
+ }
15
+ return value;
16
+ };
11
17
 
18
+ // Helper function to process metrics for storage
19
+ const processMetrics = (metrics: AchievementMetrics): Record<string, (string | number | boolean)[]> => {
20
+ return Object.entries(metrics).reduce((acc, [key, values]) => ({
21
+ ...acc,
22
+ [key]: values.map(serializeValue)
23
+ }), {});
24
+ };
12
25
 
13
26
  export interface AchievementState {
14
- config: SerializedAchievementConfiguration;
15
- metrics: AchievementMetrics;
27
+ metrics: Record<string, (string | number | boolean)[]>;
16
28
  unlockedAchievements: string[];
17
- previouslyAwardedAchievements: string[];
18
29
  storageKey: string | null;
30
+ pendingNotifications: AchievementDetails[];
19
31
  }
20
32
 
21
33
  const initialState: AchievementState = {
22
- config: {},
23
34
  metrics: {},
24
35
  unlockedAchievements: [],
25
- previouslyAwardedAchievements: [], // Initialize as empty
26
36
  storageKey: null,
37
+ pendingNotifications: [],
27
38
  };
28
39
 
29
40
  export const achievementSlice = createSlice({
30
41
  name: 'achievements',
31
42
  initialState,
32
43
  reducers: {
33
- initialize: (state, action: PayloadAction<{ config: SerializedAchievementConfiguration; initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; storageKey: string }>) => {
34
-
35
- state.config = action.payload.config;
44
+ initialize: (state, action: PayloadAction<{
45
+ initialState?: InitialAchievementMetrics & { unlockedAchievements?: string[] };
46
+ storageKey: string
47
+ }>) => {
36
48
  state.storageKey = action.payload.storageKey;
37
- const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
38
-
39
- const initialMetrics = action.payload.initialState ? Object.keys(action.payload.initialState)
40
- .filter(key => key !== 'previouslyAwardedAchievements')
41
- .reduce((acc, key) => ({ ...acc, [key]: Array.isArray(action.payload.initialState![key]) ? action.payload.initialState![key] : [action.payload.initialState![key]] }), {}) : {};
42
49
 
43
- const initialAwarded = action.payload.initialState?.previouslyAwardedAchievements || [];
44
-
45
- if (storedState) {
46
- try {
47
- const parsedState = JSON.parse(storedState);
48
- state.metrics = parsedState.achievements?.metrics || initialMetrics;
49
- state.unlockedAchievements = parsedState.achievements?.unlockedAchievements || [];
50
- state.previouslyAwardedAchievements = parsedState.achievements?.previouslyAwardedAchievements || initialAwarded; // Prioritize stored, fallback to initial
51
- } catch (error) {
52
- console.error('Error parsing stored achievement state:', error);
53
- state.metrics = initialMetrics;
54
- state.unlockedAchievements = [];
55
- state.previouslyAwardedAchievements = initialAwarded;
50
+ // Load from storage first
51
+ if (action.payload.storageKey) {
52
+ const stored = localStorage.getItem(action.payload.storageKey);
53
+ if (stored) {
54
+ try {
55
+ const parsed = JSON.parse(stored);
56
+ state.metrics = parsed.metrics || {};
57
+ state.unlockedAchievements = parsed.unlockedAchievements || [];
58
+ return;
59
+ } catch (error) {
60
+ console.error('Error parsing stored achievements:', error);
61
+ }
56
62
  }
57
- } else {
58
- state.metrics = initialMetrics;
59
- state.unlockedAchievements = [];
60
- state.previouslyAwardedAchievements = initialAwarded;
63
+ }
64
+
65
+ // If no storage or parse error, use initial state
66
+ if (action.payload.initialState) {
67
+ const { unlockedAchievements, ...metrics } = action.payload.initialState;
68
+ state.metrics = Object.entries(metrics).reduce((acc, [key, value]) => ({
69
+ ...acc,
70
+ [key]: Array.isArray(value) ? value.map(serializeValue) : [serializeValue(value as AchievementMetricValue)]
71
+ }), {});
72
+ state.unlockedAchievements = unlockedAchievements || [];
61
73
  }
62
74
  },
75
+
63
76
  setMetrics: (state, action: PayloadAction<AchievementMetrics>) => {
64
- state.metrics = action.payload;
77
+ state.metrics = processMetrics(action.payload);
65
78
  if (state.storageKey) {
66
- localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
79
+ localStorage.setItem(state.storageKey, JSON.stringify({
80
+ metrics: state.metrics,
81
+ unlockedAchievements: state.unlockedAchievements
82
+ }));
67
83
  }
68
84
  },
69
- unlockAchievement: (state, action: PayloadAction<string>) => {
70
- if (!state.unlockedAchievements.includes(action.payload)) {
71
- state.unlockedAchievements.push(action.payload);
85
+
86
+ unlockAchievement: (state, action: PayloadAction<AchievementDetails>) => {
87
+ if (!state.unlockedAchievements.includes(action.payload.achievementId)) {
88
+ state.unlockedAchievements.push(action.payload.achievementId);
89
+ state.pendingNotifications.push(action.payload);
72
90
  if (state.storageKey) {
73
- localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
91
+ localStorage.setItem(state.storageKey, JSON.stringify({
92
+ metrics: state.metrics,
93
+ unlockedAchievements: state.unlockedAchievements
94
+ }));
74
95
  }
75
96
  }
76
97
  },
77
- markAchievementAsAwarded: (state, action: PayloadAction<string>) => {
78
- if (!state.previouslyAwardedAchievements.includes(action.payload)) {
79
- state.previouslyAwardedAchievements.push(action.payload);
80
- if (state.storageKey) {
81
- localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
82
- }
83
- }
98
+
99
+ clearNotifications: (state) => {
100
+ state.pendingNotifications = [];
84
101
  },
102
+
85
103
  resetAchievements: (state) => {
86
104
  state.metrics = {};
87
105
  state.unlockedAchievements = [];
88
- state.previouslyAwardedAchievements = [];
106
+ state.pendingNotifications = [];
89
107
  if (state.storageKey) {
90
108
  localStorage.removeItem(state.storageKey);
91
109
  }
@@ -93,6 +111,6 @@ export const achievementSlice = createSlice({
93
111
  },
94
112
  });
95
113
 
96
- export const { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } = achievementSlice.actions;
114
+ export const { initialize, setMetrics, resetAchievements, unlockAchievement, clearNotifications } = achievementSlice.actions;
97
115
 
98
116
  export default achievementSlice.reducer;
@@ -1,19 +1,18 @@
1
1
  import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
- import { AchievementDetails } from '../types';
3
2
 
4
- export interface NotificationState {
5
- notifications: AchievementDetails[];
3
+ interface NotificationState {
4
+ notifications: string[];
6
5
  }
7
6
 
8
7
  const initialState: NotificationState = {
9
8
  notifications: [],
10
9
  };
11
10
 
12
- const notificationSlice = createSlice({
11
+ export const notificationSlice = createSlice({
13
12
  name: 'notifications',
14
13
  initialState,
15
14
  reducers: {
16
- addNotification: (state, action: PayloadAction<AchievementDetails>) => {
15
+ addNotification: (state, action: PayloadAction<string>) => {
17
16
  state.notifications.push(action.payload);
18
17
  },
19
18
  clearNotifications: (state) => {
@@ -23,4 +22,5 @@ const notificationSlice = createSlice({
23
22
  });
24
23
 
25
24
  export const { addNotification, clearNotifications } = notificationSlice.actions;
25
+
26
26
  export default notificationSlice.reducer;
@@ -1,18 +1,14 @@
1
1
  import { configureStore } from '@reduxjs/toolkit';
2
- import achievementReducer from '../redux/achievementSlice';
3
- import notificationReducer from '../redux/notificationSlice';
2
+ import achievementReducer from './achievementSlice';
4
3
  import { AchievementState } from './achievementSlice';
5
- import { NotificationState } from './notificationSlice';
6
4
 
7
5
  export interface RootState {
8
6
  achievements: AchievementState;
9
- notifications: NotificationState;
10
7
  }
11
8
 
12
9
  const store = configureStore({
13
10
  reducer: {
14
11
  achievements: achievementReducer,
15
- notifications: notificationReducer,
16
12
  },
17
13
  });
18
14