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.
- package/README.md +184 -339
- package/demo/src/AchievementConfig.ts +0 -37
- package/dist/defaultStyles.d.ts +0 -9
- package/dist/index.cjs.js +107 -117
- package/dist/index.d.ts +3 -2
- package/dist/index.esm.js +108 -118
- package/dist/providers/AchievementProvider.d.ts +1 -0
- package/dist/redux/achievementSlice.d.ts +1 -2
- package/package.json +11 -1
- package/rollup.config.mjs +6 -1
- package/src/defaultStyles.ts +0 -52
- package/src/hooks/useAchievement.ts +8 -11
- package/src/index.ts +14 -8
- package/src/providers/AchievementProvider.tsx +117 -136
- package/src/redux/achievementSlice.ts +65 -47
- package/src/redux/notificationSlice.ts +5 -5
- package/src/redux/store.ts +1 -5
- package/src/types.ts +12 -17
- package/tsconfig.json +3 -1
- package/demo/README.md +0 -8
- package/demo/eslint.config.js +0 -38
- package/demo/index.html +0 -13
- package/demo/package-lock.json +0 -12053
- package/demo/package.json +0 -47
- package/demo/public/vite.svg +0 -1
- package/demo/src/App.css +0 -42
- package/demo/src/App.jsx +0 -89
- package/demo/src/assets/achievements/explorer.webp +0 -0
- package/demo/src/assets/achievements/seaoned_warrior.webp +0 -0
- package/demo/src/assets/achievements/warrior.webp +0 -0
- package/demo/src/assets/react.svg +0 -1
- package/demo/src/index.css +0 -68
- package/demo/src/main.jsx +0 -10
- package/demo/vite.config.js +0 -7
- package/images/delete_local_storage.png +0 -0
- package/images/demo.gif +0 -0
- package/src/components/AchievementModal.tsx +0 -57
- package/src/hooks/useAchievementState.ts +0 -12
package/src/defaultStyles.ts
CHANGED
|
@@ -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
|
|
2
|
-
import { RootState
|
|
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
|
|
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
|
|
14
|
-
unlockedAchievements
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
3
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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,
|
|
5
|
-
import {
|
|
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
|
-
|
|
10
|
-
SerializedAchievementConfiguration,
|
|
10
|
+
AchievementConfiguration,
|
|
11
11
|
AchievementProviderProps,
|
|
12
12
|
AchievementMetrics,
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
dispatch(unlockAchievement(condition.achievementDetails));
|
|
98
|
+
setShowConfetti(true);
|
|
125
99
|
}
|
|
126
100
|
});
|
|
127
101
|
});
|
|
128
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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 (
|
|
144
|
-
|
|
135
|
+
if (showConfetti) {
|
|
136
|
+
const timer = setTimeout(() => setShowConfetti(false), 5000);
|
|
137
|
+
return () => clearTimeout(timer);
|
|
145
138
|
}
|
|
146
|
-
}, [
|
|
139
|
+
}, [showConfetti]);
|
|
147
140
|
|
|
148
|
-
|
|
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,
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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={
|
|
186
|
+
onClick={() => setShowBadges(true)}
|
|
192
187
|
position={badgesButtonPosition}
|
|
193
188
|
styles={styles.badgesButton || defaultStyles.badgesButton}
|
|
194
|
-
unlockedAchievements={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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<{
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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({
|
|
79
|
+
localStorage.setItem(state.storageKey, JSON.stringify({
|
|
80
|
+
metrics: state.metrics,
|
|
81
|
+
unlockedAchievements: state.unlockedAchievements
|
|
82
|
+
}));
|
|
67
83
|
}
|
|
68
84
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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({
|
|
91
|
+
localStorage.setItem(state.storageKey, JSON.stringify({
|
|
92
|
+
metrics: state.metrics,
|
|
93
|
+
unlockedAchievements: state.unlockedAchievements
|
|
94
|
+
}));
|
|
74
95
|
}
|
|
75
96
|
}
|
|
76
97
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
5
|
-
notifications:
|
|
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<
|
|
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;
|
package/src/redux/store.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { configureStore } from '@reduxjs/toolkit';
|
|
2
|
-
import achievementReducer from '
|
|
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
|
|