react-achievements 2.1.0 → 2.1.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.
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
- import { AchievementProviderProps, AchievementMetrics as AchievementMetricsType } from '../types';
2
+ import { AchievementProviderProps, AchievementMetrics } from '../types';
3
3
  export interface AchievementContextType {
4
- updateMetrics: (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => void;
4
+ updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
5
5
  unlockedAchievements: string[];
6
6
  resetStorage: () => void;
7
7
  }
8
8
  export declare const AchievementContext: React.Context<AchievementContextType | undefined>;
9
9
  export declare const useAchievementContext: () => AchievementContextType;
10
- export declare const AchievementProvider: React.FC<AchievementProviderProps>;
11
- export declare function mergeDeep(target: any, source: any): any;
10
+ declare const AchievementProvider: React.FC<AchievementProviderProps>;
11
+ 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,7 +9,7 @@ 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
  };
@@ -21,7 +21,7 @@ export declare const achievementSlice: import("@reduxjs/toolkit").Slice<Achievem
21
21
  resetAchievements: (state: import("immer/dist/internal").WritableDraft<AchievementState>) => void;
22
22
  }, "achievements">;
23
23
  export declare const initialize: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
24
- config: AchievementConfiguration;
24
+ config: SerializedAchievementConfiguration;
25
25
  initialState?: InitialAchievementMetrics & {
26
26
  previouslyAwardedAchievements?: string[];
27
27
  };
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-achievements",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "This package allows users to transpose a React achievements engine over their React apps",
5
5
  "keywords": [
6
6
  "react",
@@ -26,6 +26,7 @@
26
26
  "@mui/icons-material": "^6.4.8",
27
27
  "@rollup/plugin-commonjs": "^26.0.1",
28
28
  "@rollup/plugin-node-resolve": "^15.2.3",
29
+ "@rollup/plugin-replace": "^6.0.2",
29
30
  "@storybook/addon-essentials": "^8.6.8",
30
31
  "@storybook/addon-interactions": "^8.6.8",
31
32
  "@storybook/addon-links": "^8.6.8",
@@ -35,23 +36,21 @@
35
36
  "@storybook/react": "^8.6.8",
36
37
  "@storybook/react-webpack5": "^8.6.8",
37
38
  "@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
39
  "@types/jest": "^29.5.12",
43
40
  "@types/node": "^20.14.12",
44
41
  "@types/react": "^18.3.3",
45
- "@types/react-dom": "^18.3.0"
42
+ "@types/react-dom": "^18.3.0",
43
+ "rollup": "^4.19.0",
44
+ "rollup-plugin-typescript2": "^0.36.0",
45
+ "storybook": "^8.6.8",
46
+ "typescript": "^5.5.4"
46
47
  },
47
48
  "peerDependencies": {
49
+ "@reduxjs/toolkit": "^1.0.0",
48
50
  "react": "^18.0.0",
51
+ "react-confetti": "^6.0.0",
49
52
  "react-dom": "^18.0.0",
50
53
  "react-redux": "^8.0.0 || ^9.0.0",
51
- "@reduxjs/toolkit": "^1.0.0",
52
- "react-confetti": "^6.0.0",
53
54
  "react-use": "^17.0.0"
54
- },
55
- "dependencies": {
56
55
  }
57
- }
56
+ }
package/rollup.config.mjs CHANGED
@@ -1,7 +1,9 @@
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';
4
5
 
6
+ // Add the replace plugin to handle `this` issues in libraries like @reduxjs/toolkit
5
7
  export default {
6
8
  input: 'src/index.ts',
7
9
  output: [
@@ -15,6 +17,10 @@ export default {
15
17
  }
16
18
  ],
17
19
  plugins: [
20
+ replace({
21
+ preventAssignment: true,
22
+ 'this': 'undefined',
23
+ }),
18
24
  resolve(),
19
25
  commonjs(),
20
26
  typescript({ tsconfig: './tsconfig.json' })
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useCallback } from 'react';
1
+ import React, { useEffect, useCallback, useState, useMemo } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import { RootState, AppDispatch } from '../redux/store';
4
4
  import { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } from '../redux/achievementSlice';
@@ -6,19 +6,21 @@ import { addNotification, clearNotifications } from '../redux/notificationSlice'
6
6
  import {
7
7
  AchievementDetails,
8
8
  AchievementMetricValue,
9
- AchievementUnlockCondition,
9
+ SerializedAchievementUnlockCondition,
10
+ SerializedAchievementConfiguration,
10
11
  AchievementProviderProps,
11
- AchievementMetrics as AchievementMetricsType,
12
+ AchievementMetrics,
13
+ AchievementConfiguration,
14
+ AchievementUnlockCondition,
12
15
  } from '../types';
13
- import { defaultStyles, Styles } from '../defaultStyles';
14
16
  import AchievementModal from '../components/AchievementModal';
15
- import BadgesModal from '../components/BadgesModal';
16
17
  import BadgesButton from '../components/BadgesButton';
18
+ import BadgesModal from '../components/BadgesModal';
17
19
  import ConfettiWrapper from '../components/ConfettiWrapper';
18
- import { defaultAchievementIcons } from '../assets/defaultIcons';
20
+ import { defaultStyles } from '../defaultStyles';
19
21
 
20
22
  export interface AchievementContextType {
21
- updateMetrics: (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => void;
23
+ updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
22
24
  unlockedAchievements: string[];
23
25
  resetStorage: () => void;
24
26
  }
@@ -33,87 +35,105 @@ export const useAchievementContext = () => {
33
35
  return context;
34
36
  };
35
37
 
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) => {
38
+ const AchievementProvider: React.FC<AchievementProviderProps> = ({
39
+ children,
40
+ config,
41
+ initialState = {},
42
+ storageKey = 'react-achievements',
43
+ badgesButtonPosition = 'top-right',
44
+ styles = {},
45
+ icons = {},
46
+ }) => {
45
47
  const dispatch: AppDispatch = useDispatch();
46
48
  const metrics = useSelector((state: RootState) => state.achievements.metrics);
47
49
  const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
48
- const previouslyAwardedAchievementIds = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
50
+ const previouslyAwardedAchievements = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
49
51
  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
- );
52
+ const [currentAchievement, setCurrentAchievement] = useState<AchievementDetails | null>(null);
53
+ const [showBadges, setShowBadges] = useState(false);
54
+ const [showConfetti, setShowConfetti] = useState(false);
55
+
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
+ }
64
85
 
65
- const resetStorage = useCallback(() => {
66
- localStorage.removeItem(storageKey);
67
- dispatch(resetAchievements());
68
- }, [dispatch, storageKey]);
86
+ return {
87
+ achievementDetails: condition.achievementDetails,
88
+ conditionType,
89
+ conditionValue,
90
+ };
91
+ });
92
+ });
93
+ return serializedConfig;
94
+ };
69
95
 
70
- useEffect(() => {
71
- dispatch(initialize({ config, initialState, storageKey }));
72
- }, [dispatch, config, initialState, storageKey]);
96
+ const serializedConfig = useMemo(() => serializeConfig(config), [config]);
73
97
 
74
98
  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;
99
+ const newAchievements: AchievementDetails[] = [];
100
+ Object.entries(serializedConfig).forEach(([metricName, conditions]) => {
101
+ const metricValues = metrics[metricName];
102
+ if (!metricValues) return;
103
+ 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
+ if (
120
+ metricValues.some((value: AchievementMetricValue) => isConditionMet(value)) &&
121
+ !unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
122
+ !previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId)
123
+ ) {
124
+ newAchievements.push(condition.achievementDetails);
88
125
  }
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
126
  });
108
-
109
- if (newAchievementsToAward.length > 0) {
110
- newAchievementsToAward.forEach((achievement) => {
111
- dispatch(addNotification(achievement));
127
+ });
128
+ if (newAchievements.length > 0) {
129
+ newAchievements.forEach((achievement) => {
130
+ dispatch(unlockAchievement(achievement.achievementId));
112
131
  dispatch(markAchievementAsAwarded(achievement.achievementId));
132
+ dispatch(addNotification(achievement));
113
133
  });
114
134
  setShowConfetti(true);
115
135
  }
116
- }, [config, metrics, unlockedAchievementIds, previouslyAwardedAchievementIds, dispatch]);
136
+ }, [serializedConfig, metrics, unlockedAchievementIds, previouslyAwardedAchievements, dispatch]);
117
137
 
118
138
  useEffect(() => {
119
139
  checkAchievements();
@@ -127,71 +147,75 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
127
147
 
128
148
  const showBadgesModal = () => setShowBadges(true);
129
149
 
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);
150
+ useEffect(() => {
151
+ dispatch(initialize({
152
+ config: serializedConfig,
153
+ initialState,
154
+ storageKey,
155
+ }));
156
+ }, [dispatch, serializedConfig, initialState, storageKey]);
143
157
 
144
158
  return (
145
- <AchievementContext.Provider value={{ updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage }}>
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
+ >
146
177
  {children}
178
+ <ConfettiWrapper show={showConfetti} />
147
179
  <AchievementModal
148
180
  isOpen={!!currentAchievement}
149
181
  achievement={currentAchievement}
150
182
  onClose={() => {
151
183
  setCurrentAchievement(null);
152
- if (currentAchievement) {
153
- dispatch(clearNotifications());
154
- }
184
+ dispatch(clearNotifications());
185
+ setShowConfetti(false);
155
186
  }}
156
- styles={mergedStyles.achievementModal}
157
- icons={mergedIcons}
158
- />
159
- <BadgesModal
160
- isOpen={showBadges}
161
- achievements={previouslyAwardedAchievementsDetails}
162
- onClose={() => setShowBadges(false)}
163
- styles={mergedStyles.badgesModal}
164
- icons={mergedIcons}
187
+ styles={styles.achievementModal || defaultStyles.achievementModal}
188
+ icons={icons}
165
189
  />
166
190
  <BadgesButton
167
191
  onClick={showBadgesModal}
168
192
  position={badgesButtonPosition}
169
- styles={mergedStyles.badgesButton}
170
- unlockedAchievements={previouslyAwardedAchievementsDetails}
193
+ 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)}
202
+ />
203
+ <BadgesModal
204
+ 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)}
213
+ onClose={() => setShowBadges(false)}
214
+ styles={styles.badgesModal || defaultStyles.badgesModal}
215
+ icons={icons}
171
216
  />
172
- <ConfettiWrapper show={showConfetti || notifications.length > 0} />
173
217
  </AchievementContext.Provider>
174
218
  );
175
219
  };
176
220
 
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
- }
221
+ export { AchievementProvider };
@@ -4,10 +4,14 @@ import {
4
4
  AchievementConfiguration,
5
5
  InitialAchievementMetrics,
6
6
  AchievementMetrics,
7
+ AchievementDetails,
8
+ SerializedAchievementConfiguration,
7
9
  } from '../types';
8
10
 
11
+
12
+
9
13
  export interface AchievementState {
10
- config: AchievementConfiguration;
14
+ config: SerializedAchievementConfiguration;
11
15
  metrics: AchievementMetrics;
12
16
  unlockedAchievements: string[];
13
17
  previouslyAwardedAchievements: string[];
@@ -26,7 +30,8 @@ export const achievementSlice = createSlice({
26
30
  name: 'achievements',
27
31
  initialState,
28
32
  reducers: {
29
- initialize: (state, action: PayloadAction<{ config: AchievementConfiguration; initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; storageKey: string }>) => {
33
+ initialize: (state, action: PayloadAction<{ config: SerializedAchievementConfiguration; initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; storageKey: string }>) => {
34
+
30
35
  state.config = action.payload.config;
31
36
  state.storageKey = action.payload.storageKey;
32
37
  const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
package/src/types.ts CHANGED
@@ -31,4 +31,14 @@ export interface AchievementProviderProps {
31
31
  export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
32
32
  isConditionMet: (value: T) => boolean;
33
33
  achievementDetails: AchievementDetails;
34
+ }
35
+
36
+ export interface SerializedAchievementUnlockCondition {
37
+ achievementDetails: AchievementDetails;
38
+ conditionType: 'number' | 'string' | 'boolean' | 'date';
39
+ conditionValue: any;
40
+ }
41
+
42
+ export interface SerializedAchievementConfiguration {
43
+ [metricName: string]: SerializedAchievementUnlockCondition[];
34
44
  }