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.
- package/README.md +101 -2
- package/dist/hooks/useAchievement.d.ts +1 -1
- package/dist/hooks/useAchievementState.d.ts +4 -0
- package/dist/index.cjs.js +298 -221
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +302 -226
- package/dist/providers/AchievementProvider.d.ts +4 -4
- package/dist/redux/achievementSlice.d.ts +13 -7
- package/dist/types.d.ts +13 -2
- package/package.json +10 -11
- package/rollup.config.mjs +6 -0
- package/src/hooks/useAchievementState.ts +12 -0
- package/src/index.ts +2 -0
- package/src/providers/AchievementProvider.tsx +138 -102
- package/src/redux/achievementSlice.ts +35 -8
- package/src/types.ts +14 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { AchievementProviderProps, AchievementMetrics
|
|
2
|
+
import { AchievementProviderProps, AchievementMetrics } from '../types';
|
|
3
3
|
export interface AchievementContextType {
|
|
4
|
-
updateMetrics: (newMetrics:
|
|
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
|
-
|
|
11
|
-
export
|
|
10
|
+
declare const AchievementProvider: React.FC<AchievementProviderProps>;
|
|
11
|
+
export { AchievementProvider };
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { PayloadAction } from '@reduxjs/toolkit';
|
|
2
|
-
import {
|
|
2
|
+
import { InitialAchievementMetrics, AchievementMetrics, SerializedAchievementConfiguration } from '../types';
|
|
3
3
|
export interface AchievementState {
|
|
4
|
-
config:
|
|
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:
|
|
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:
|
|
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<
|
|
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.
|
|
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
|
-
|
|
9
|
+
SerializedAchievementUnlockCondition,
|
|
10
|
+
SerializedAchievementConfiguration,
|
|
10
11
|
AchievementProviderProps,
|
|
11
|
-
AchievementMetrics
|
|
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 {
|
|
20
|
+
import { defaultStyles } from '../defaultStyles';
|
|
19
21
|
|
|
20
22
|
export interface AchievementContextType {
|
|
21
|
-
updateMetrics: (newMetrics:
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
const [
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
return {
|
|
87
|
+
achievementDetails: condition.achievementDetails,
|
|
88
|
+
conditionType,
|
|
89
|
+
conditionValue,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
return serializedConfig;
|
|
94
|
+
};
|
|
68
95
|
|
|
69
|
-
|
|
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) =>
|
|
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
|
-
}, [
|
|
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
|
-
|
|
120
|
-
(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
184
|
+
dispatch(clearNotifications());
|
|
185
|
+
setShowConfetti(false);
|
|
143
186
|
}}
|
|
144
|
-
styles={
|
|
145
|
-
icons={
|
|
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={
|
|
158
|
-
unlockedAchievements={
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
53
|
+
state.metrics = initialMetrics;
|
|
38
54
|
state.unlockedAchievements = [];
|
|
55
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
39
56
|
}
|
|
40
57
|
} else {
|
|
41
|
-
state.metrics =
|
|
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<
|
|
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
|
}
|