react-achievements 2.0.5 → 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,25 +1,31 @@
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
+ previouslyAwardedAchievements: string[];
7
8
  storageKey: string | null;
8
9
  }
9
10
  export declare const achievementSlice: import("@reduxjs/toolkit").Slice<AchievementState, {
10
11
  initialize: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<{
11
- config: AchievementConfiguration;
12
- initialState?: InitialAchievementMetrics;
12
+ config: SerializedAchievementConfiguration;
13
+ initialState?: InitialAchievementMetrics & {
14
+ previouslyAwardedAchievements?: string[];
15
+ };
13
16
  storageKey: string;
14
17
  }>) => void;
15
18
  setMetrics: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<AchievementMetrics>) => void;
16
19
  unlockAchievement: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
20
+ markAchievementAsAwarded: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
17
21
  resetAchievements: (state: import("immer/dist/internal").WritableDraft<AchievementState>) => void;
18
22
  }, "achievements">;
19
23
  export declare const initialize: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
20
- config: AchievementConfiguration;
21
- initialState?: InitialAchievementMetrics;
24
+ config: SerializedAchievementConfiguration;
25
+ initialState?: InitialAchievementMetrics & {
26
+ previouslyAwardedAchievements?: string[];
27
+ };
22
28
  storageKey: string;
23
- }, "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">;
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">;
24
30
  declare const _default: import("@reduxjs/toolkit").Reducer<AchievementState>;
25
31
  export default _default;
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Styles } from "./defaultStyles";
1
2
  export type AchievementMetricValue = number | string | boolean | Date;
2
3
  export interface AchievementDetails {
3
4
  achievementId: string;
@@ -14,13 +15,23 @@ export type AchievementMetrics = Record<string, AchievementMetricValue[]>;
14
15
  export interface AchievementProviderProps {
15
16
  children: React.ReactNode;
16
17
  config: AchievementConfiguration;
17
- initialState?: InitialAchievementMetrics;
18
+ initialState?: InitialAchievementMetrics & {
19
+ previouslyAwardedAchievements?: string[];
20
+ };
18
21
  storageKey?: string;
19
22
  badgesButtonPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
20
- styles?: Partial<import('./defaultStyles').Styles>;
23
+ styles?: Partial<Styles>;
21
24
  icons?: Record<string, string>;
22
25
  }
23
26
  export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
24
27
  isConditionMet: (value: T) => boolean;
25
28
  achievementDetails: AchievementDetails;
26
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.0.5",
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' })
@@ -0,0 +1,12 @@
1
+ import { useSelector } from 'react-redux';
2
+ import { RootState } from '../redux/store';
3
+
4
+ export const useAchievementState = () => {
5
+ const metrics = useSelector((state: RootState) => state.achievements.metrics);
6
+ const previouslyAwardedAchievements = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
7
+
8
+ return {
9
+ metrics,
10
+ previouslyAwardedAchievements,
11
+ };
12
+ };
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { AchievementMetrics, AchievementConfiguration, AchievementDetails, Achie
3
3
  import ConfettiWrapper from './components/ConfettiWrapper';
4
4
  import achievementReducer from './redux/achievementSlice';
5
5
  import notificationReducer from './redux/notificationSlice'
6
+ import { useAchievementState } from './hooks/useAchievementState';
6
7
 
7
8
  export {
8
9
  AchievementProvider,
@@ -14,4 +15,5 @@ export {
14
15
  ConfettiWrapper,
15
16
  achievementReducer,
16
17
  notificationReducer,
18
+ useAchievementState,
17
19
  };
@@ -1,24 +1,26 @@
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
- import { initialize, setMetrics, unlockAchievement, resetAchievements } from '../redux/achievementSlice';
4
+ import { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } from '../redux/achievementSlice';
5
5
  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,76 +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);
50
+ const previouslyAwardedAchievements = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
48
51
  const notifications = useSelector((state: RootState) => state.notifications.notifications);
49
- const mergedStyles = React.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
50
-
51
- const [currentAchievement, setCurrentAchievement] = React.useState<AchievementDetails | null>(null);
52
- const [showBadges, setShowBadges] = React.useState(false);
53
- const [showConfetti, setShowConfetti] = React.useState(false);
54
-
55
- const mergedIcons = React.useMemo(() => ({ ...defaultAchievementIcons, ...icons }), [icons]);
52
+ const [currentAchievement, setCurrentAchievement] = useState<AchievementDetails | null>(null);
53
+ const [showBadges, setShowBadges] = useState(false);
54
+ const [showConfetti, setShowConfetti] = useState(false);
56
55
 
57
- const updateMetrics = useCallback(
58
- (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => {
59
- dispatch(setMetrics(typeof newMetrics === 'function' ? newMetrics(metrics) : newMetrics));
60
- },
61
- [dispatch, metrics]
62
- );
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
+ }
63
85
 
64
- const resetStorage = useCallback(() => {
65
- localStorage.removeItem(storageKey);
66
- dispatch(resetAchievements());
67
- }, [dispatch, storageKey]);
86
+ return {
87
+ achievementDetails: condition.achievementDetails,
88
+ conditionType,
89
+ conditionValue,
90
+ };
91
+ });
92
+ });
93
+ return serializedConfig;
94
+ };
68
95
 
69
- useEffect(() => {
70
- dispatch(initialize({ config, initialState, storageKey }));
71
- }, [dispatch, config, initialState, storageKey]);
96
+ const serializedConfig = useMemo(() => serializeConfig(config), [config]);
72
97
 
73
98
  const checkAchievements = useCallback(() => {
74
99
  const newAchievements: AchievementDetails[] = [];
75
-
76
- if (!unlockedAchievementIds) {
77
- console.error('unlockedAchievements is undefined!');
78
- return;
79
- }
80
-
81
- Object.entries(config).forEach(([metricName, conditions]) => {
100
+ Object.entries(serializedConfig).forEach(([metricName, conditions]) => {
82
101
  const metricValues = metrics[metricName];
83
-
84
- if (!metricValues) {
85
- return;
86
- }
87
-
102
+ if (!metricValues) return;
88
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
+ };
89
119
  if (
90
- metricValues.some((value: AchievementMetricValue) => condition.isConditionMet(value)) &&
91
- !unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
120
+ metricValues.some((value: AchievementMetricValue) => isConditionMet(value)) &&
121
+ !unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
122
+ !previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId)
92
123
  ) {
93
124
  newAchievements.push(condition.achievementDetails);
94
125
  }
95
126
  });
96
127
  });
97
-
98
128
  if (newAchievements.length > 0) {
99
129
  newAchievements.forEach((achievement) => {
100
130
  dispatch(unlockAchievement(achievement.achievementId));
131
+ dispatch(markAchievementAsAwarded(achievement.achievementId));
101
132
  dispatch(addNotification(achievement));
102
133
  });
103
134
  setShowConfetti(true);
104
135
  }
105
- }, [config, metrics, unlockedAchievementIds, dispatch]);
136
+ }, [serializedConfig, metrics, unlockedAchievementIds, previouslyAwardedAchievements, dispatch]);
106
137
 
107
138
  useEffect(() => {
108
139
  checkAchievements();
@@ -116,70 +147,75 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
116
147
 
117
148
  const showBadgesModal = () => setShowBadges(true);
118
149
 
119
- const getAchievements = useCallback(
120
- (achievedIds: string[]) => {
121
- return Object.values(config).flatMap((conditions) =>
122
- conditions
123
- .filter((c) => achievedIds.includes(c.achievementDetails.achievementId))
124
- .map((c) => c.achievementDetails)
125
- );
126
- },
127
- [config]
128
- );
129
-
130
- const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
150
+ useEffect(() => {
151
+ dispatch(initialize({
152
+ config: serializedConfig,
153
+ initialState,
154
+ storageKey,
155
+ }));
156
+ }, [dispatch, serializedConfig, initialState, storageKey]);
131
157
 
132
158
  return (
133
- <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
+ >
134
177
  {children}
178
+ <ConfettiWrapper show={showConfetti} />
135
179
  <AchievementModal
136
180
  isOpen={!!currentAchievement}
137
181
  achievement={currentAchievement}
138
182
  onClose={() => {
139
183
  setCurrentAchievement(null);
140
- if (currentAchievement) {
141
- dispatch(clearNotifications());
142
- }
184
+ dispatch(clearNotifications());
185
+ setShowConfetti(false);
143
186
  }}
144
- styles={mergedStyles.achievementModal}
145
- icons={mergedIcons}
146
- />
147
- <BadgesModal
148
- isOpen={showBadges}
149
- achievements={unlockedAchievementsDetails}
150
- onClose={() => setShowBadges(false)}
151
- styles={mergedStyles.badgesModal}
152
- icons={mergedIcons}
187
+ styles={styles.achievementModal || defaultStyles.achievementModal}
188
+ icons={icons}
153
189
  />
154
190
  <BadgesButton
155
191
  onClick={showBadgesModal}
156
192
  position={badgesButtonPosition}
157
- styles={mergedStyles.badgesButton}
158
- unlockedAchievements={unlockedAchievementsDetails}
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}
159
216
  />
160
- <ConfettiWrapper show={showConfetti || notifications.length > 0} />
161
217
  </AchievementContext.Provider>
162
218
  );
163
219
  };
164
220
 
165
- function isObject(item: any) {
166
- return item && typeof item === 'object' && !Array.isArray(item);
167
- }
168
-
169
- export function mergeDeep(target: any, source: any) {
170
- const output = { ...target };
171
- if (isObject(target) && isObject(source)) {
172
- Object.keys(source).forEach((key) => {
173
- if (isObject(source[key])) {
174
- if (!(key in target)) {
175
- output[key] = source[key];
176
- } else {
177
- output[key] = mergeDeep(target[key], source[key]);
178
- }
179
- } else {
180
- output[key] = source[key];
181
- }
182
- });
183
- }
184
- return output;
185
- }
221
+ export { AchievementProvider };
@@ -1,14 +1,20 @@
1
+ // src/redux/achievementSlice.ts
1
2
  import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
3
  import {
3
4
  AchievementConfiguration,
4
5
  InitialAchievementMetrics,
5
6
  AchievementMetrics,
7
+ AchievementDetails,
8
+ SerializedAchievementConfiguration,
6
9
  } from '../types';
7
10
 
11
+
12
+
8
13
  export interface AchievementState {
9
- config: AchievementConfiguration;
14
+ config: SerializedAchievementConfiguration;
10
15
  metrics: AchievementMetrics;
11
16
  unlockedAchievements: string[];
17
+ previouslyAwardedAchievements: string[];
12
18
  storageKey: string | null;
13
19
  }
14
20
 
@@ -16,6 +22,7 @@ const initialState: AchievementState = {
16
22
  config: {},
17
23
  metrics: {},
18
24
  unlockedAchievements: [],
25
+ previouslyAwardedAchievements: [], // Initialize as empty
19
26
  storageKey: null,
20
27
  };
21
28
 
@@ -23,42 +30,62 @@ export const achievementSlice = createSlice({
23
30
  name: 'achievements',
24
31
  initialState,
25
32
  reducers: {
26
- initialize: (state, action: PayloadAction<{ config: AchievementConfiguration; initialState?: InitialAchievementMetrics; storageKey: string }>) => {
33
+ initialize: (state, action: PayloadAction<{ config: SerializedAchievementConfiguration; initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; storageKey: string }>) => {
34
+
27
35
  state.config = action.payload.config;
28
36
  state.storageKey = action.payload.storageKey;
29
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
+
43
+ const initialAwarded = action.payload.initialState?.previouslyAwardedAchievements || [];
44
+
30
45
  if (storedState) {
31
46
  try {
32
47
  const parsedState = JSON.parse(storedState);
33
- state.metrics = parsedState.achievements?.metrics || {};
48
+ state.metrics = parsedState.achievements?.metrics || initialMetrics;
34
49
  state.unlockedAchievements = parsedState.achievements?.unlockedAchievements || [];
50
+ state.previouslyAwardedAchievements = parsedState.achievements?.previouslyAwardedAchievements || initialAwarded; // Prioritize stored, fallback to initial
35
51
  } catch (error) {
36
52
  console.error('Error parsing stored achievement state:', error);
37
- state.metrics = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => ({ ...acc, [key]: Array.isArray(action.payload.initialState![key]) ? action.payload.initialState![key] : [action.payload.initialState![key]] }), {}) : {};
53
+ state.metrics = initialMetrics;
38
54
  state.unlockedAchievements = [];
55
+ state.previouslyAwardedAchievements = initialAwarded;
39
56
  }
40
57
  } else {
41
- state.metrics = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => ({ ...acc, [key]: Array.isArray(action.payload.initialState![key]) ? action.payload.initialState![key] : [action.payload.initialState![key]] }), {}) : {};
58
+ state.metrics = initialMetrics;
42
59
  state.unlockedAchievements = [];
60
+ state.previouslyAwardedAchievements = initialAwarded;
43
61
  }
44
62
  },
45
63
  setMetrics: (state, action: PayloadAction<AchievementMetrics>) => {
46
64
  state.metrics = action.payload;
47
65
  if (state.storageKey) {
48
- localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
66
+ localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
49
67
  }
50
68
  },
51
69
  unlockAchievement: (state, action: PayloadAction<string>) => {
52
70
  if (!state.unlockedAchievements.includes(action.payload)) {
53
71
  state.unlockedAchievements.push(action.payload);
54
72
  if (state.storageKey) {
55
- localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
73
+ localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
74
+ }
75
+ }
76
+ },
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 } }));
56
82
  }
57
83
  }
58
84
  },
59
85
  resetAchievements: (state) => {
60
86
  state.metrics = {};
61
87
  state.unlockedAchievements = [];
88
+ state.previouslyAwardedAchievements = [];
62
89
  if (state.storageKey) {
63
90
  localStorage.removeItem(state.storageKey);
64
91
  }
@@ -66,6 +93,6 @@ export const achievementSlice = createSlice({
66
93
  },
67
94
  });
68
95
 
69
- export const { initialize, setMetrics, unlockAchievement, resetAchievements } = achievementSlice.actions;
96
+ export const { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } = achievementSlice.actions;
70
97
 
71
98
  export default achievementSlice.reducer;
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import {Styles} from "./defaultStyles";
2
+
1
3
  export type AchievementMetricValue = number | string | boolean | Date;
2
4
 
3
5
  export interface AchievementDetails {
@@ -19,14 +21,24 @@ export type AchievementMetrics = Record<string, AchievementMetricValue[]>;
19
21
  export interface AchievementProviderProps {
20
22
  children: React.ReactNode;
21
23
  config: AchievementConfiguration;
22
- initialState?: InitialAchievementMetrics;
24
+ initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; // Add optional previouslyAwardedAchievements
23
25
  storageKey?: string;
24
26
  badgesButtonPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
25
- styles?: Partial<import('./defaultStyles').Styles>;
27
+ styles?: Partial<Styles>;
26
28
  icons?: Record<string, string>;
27
29
  }
28
30
 
29
31
  export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
30
32
  isConditionMet: (value: T) => boolean;
31
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[];
32
44
  }