react-achievements 2.1.0 → 2.2.0
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 +183 -338
- package/dist/defaultStyles.d.ts +0 -9
- package/dist/hooks/useAchievement.d.ts +1 -1
- package/dist/index.cjs.js +290 -262
- package/dist/index.d.ts +3 -2
- package/dist/index.esm.js +293 -265
- package/dist/providers/AchievementProvider.d.ts +5 -4
- package/dist/redux/achievementSlice.d.ts +5 -6
- package/dist/types.d.ts +8 -0
- package/package.json +18 -9
- package/rollup.config.mjs +11 -0
- package/src/defaultStyles.ts +0 -52
- package/src/hooks/useAchievement.ts +8 -11
- package/src/index.ts +14 -8
- package/src/providers/AchievementProvider.tsx +147 -142
- package/src/redux/achievementSlice.ts +68 -45
- package/src/redux/notificationSlice.ts +5 -5
- package/src/redux/store.ts +1 -5
- package/src/types.ts +12 -7
- 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/AchievementConfig.ts +0 -37
- 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/src/components/AchievementModal.tsx +0 -57
- package/src/hooks/useAchievementState.ts +0 -12
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import
|
|
2
|
+
import 'react-toastify/dist/ReactToastify.css';
|
|
3
|
+
import { AchievementProviderProps, AchievementMetrics } from '../types';
|
|
3
4
|
export interface AchievementContextType {
|
|
4
|
-
updateMetrics: (newMetrics:
|
|
5
|
+
updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
|
|
5
6
|
unlockedAchievements: string[];
|
|
6
7
|
resetStorage: () => void;
|
|
7
8
|
}
|
|
8
9
|
export declare const AchievementContext: React.Context<AchievementContextType | undefined>;
|
|
9
10
|
export declare const useAchievementContext: () => AchievementContextType;
|
|
10
|
-
|
|
11
|
-
export
|
|
11
|
+
declare const AchievementProvider: React.FC<AchievementProviderProps>;
|
|
12
|
+
export { AchievementProvider };
|
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
previouslyAwardedAchievements: string[];
|
|
@@ -9,23 +9,22 @@ 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:
|
|
12
|
+
config: SerializedAchievementConfiguration;
|
|
13
13
|
initialState?: InitialAchievementMetrics & {
|
|
14
14
|
previouslyAwardedAchievements?: string[];
|
|
15
15
|
};
|
|
16
16
|
storageKey: string;
|
|
17
17
|
}>) => void;
|
|
18
18
|
setMetrics: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<AchievementMetrics>) => void;
|
|
19
|
-
unlockAchievement: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
|
|
20
19
|
markAchievementAsAwarded: (state: import("immer/dist/internal").WritableDraft<AchievementState>, action: PayloadAction<string>) => void;
|
|
21
20
|
resetAchievements: (state: import("immer/dist/internal").WritableDraft<AchievementState>) => void;
|
|
22
21
|
}, "achievements">;
|
|
23
22
|
export declare const initialize: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
|
|
24
|
-
config:
|
|
23
|
+
config: SerializedAchievementConfiguration;
|
|
25
24
|
initialState?: InitialAchievementMetrics & {
|
|
26
25
|
previouslyAwardedAchievements?: string[];
|
|
27
26
|
};
|
|
28
27
|
storageKey: string;
|
|
29
|
-
}, "achievements/initialize">, setMetrics: import("@reduxjs/toolkit").ActionCreatorWithPayload<AchievementMetrics, "achievements/setMetrics">,
|
|
28
|
+
}, "achievements/initialize">, setMetrics: import("@reduxjs/toolkit").ActionCreatorWithPayload<AchievementMetrics, "achievements/setMetrics">, resetAchievements: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"achievements/resetAchievements">, markAchievementAsAwarded: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "achievements/markAchievementAsAwarded">;
|
|
30
29
|
declare const _default: import("@reduxjs/toolkit").Reducer<AchievementState>;
|
|
31
30
|
export default _default;
|
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,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-achievements",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "This package allows users to transpose a React achievements engine over their React apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
7
7
|
"badge",
|
|
8
8
|
"achievement"
|
|
9
9
|
],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/dave-b-b/react-achievements.git"
|
|
13
|
+
},
|
|
10
14
|
"main": "dist/cjs.js",
|
|
11
15
|
"module": "dist/index.esm.js",
|
|
12
16
|
"types": "dist/index.d.ts",
|
|
@@ -26,6 +30,7 @@
|
|
|
26
30
|
"@mui/icons-material": "^6.4.8",
|
|
27
31
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
28
32
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
33
|
+
"@rollup/plugin-replace": "^6.0.2",
|
|
29
34
|
"@storybook/addon-essentials": "^8.6.8",
|
|
30
35
|
"@storybook/addon-interactions": "^8.6.8",
|
|
31
36
|
"@storybook/addon-links": "^8.6.8",
|
|
@@ -35,23 +40,27 @@
|
|
|
35
40
|
"@storybook/react": "^8.6.8",
|
|
36
41
|
"@storybook/react-webpack5": "^8.6.8",
|
|
37
42
|
"@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
43
|
"@types/jest": "^29.5.12",
|
|
43
44
|
"@types/node": "^20.14.12",
|
|
44
45
|
"@types/react": "^18.3.3",
|
|
45
|
-
"@types/react-dom": "^18.3.0"
|
|
46
|
+
"@types/react-dom": "^18.3.0",
|
|
47
|
+
"postcss": "^8.5.3",
|
|
48
|
+
"rollup": "^4.19.0",
|
|
49
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
50
|
+
"rollup-plugin-typescript2": "^0.36.0",
|
|
51
|
+
"storybook": "^8.6.8",
|
|
52
|
+
"typescript": "^5.5.4"
|
|
46
53
|
},
|
|
47
54
|
"peerDependencies": {
|
|
55
|
+
"@reduxjs/toolkit": "^1.0.0",
|
|
48
56
|
"react": "^18.0.0",
|
|
57
|
+
"react-confetti": "^6.0.0",
|
|
49
58
|
"react-dom": "^18.0.0",
|
|
50
59
|
"react-redux": "^8.0.0 || ^9.0.0",
|
|
51
|
-
"
|
|
52
|
-
"react-confetti": "^6.0.0",
|
|
60
|
+
"react-toastify": "^10.0.0",
|
|
53
61
|
"react-use": "^17.0.0"
|
|
54
62
|
},
|
|
55
63
|
"dependencies": {
|
|
64
|
+
"react-toastify": "^10.0.0"
|
|
56
65
|
}
|
|
57
|
-
}
|
|
66
|
+
}
|
package/rollup.config.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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';
|
|
5
|
+
import postcss from 'rollup-plugin-postcss';
|
|
4
6
|
|
|
5
7
|
export default {
|
|
6
8
|
input: 'src/index.ts',
|
|
@@ -15,6 +17,15 @@ export default {
|
|
|
15
17
|
}
|
|
16
18
|
],
|
|
17
19
|
plugins: [
|
|
20
|
+
replace({
|
|
21
|
+
preventAssignment: true,
|
|
22
|
+
'this': 'undefined',
|
|
23
|
+
}),
|
|
24
|
+
postcss({
|
|
25
|
+
extract: false,
|
|
26
|
+
inject: true,
|
|
27
|
+
modules: false
|
|
28
|
+
}),
|
|
18
29
|
resolve(),
|
|
19
30
|
commonjs(),
|
|
20
31
|
typescript({ tsconfig: './tsconfig.json' })
|
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,24 +1,24 @@
|
|
|
1
|
-
import React, { useEffect, useCallback } 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
|
+
AchievementConfiguration,
|
|
10
11
|
AchievementProviderProps,
|
|
11
|
-
AchievementMetrics
|
|
12
|
+
AchievementMetrics,
|
|
13
|
+
AchievementState
|
|
12
14
|
} from '../types';
|
|
13
|
-
import { defaultStyles, Styles } from '../defaultStyles';
|
|
14
|
-
import AchievementModal from '../components/AchievementModal';
|
|
15
|
-
import BadgesModal from '../components/BadgesModal';
|
|
16
15
|
import BadgesButton from '../components/BadgesButton';
|
|
16
|
+
import BadgesModal from '../components/BadgesModal';
|
|
17
17
|
import ConfettiWrapper from '../components/ConfettiWrapper';
|
|
18
|
-
import {
|
|
18
|
+
import { defaultStyles } from '../defaultStyles';
|
|
19
19
|
|
|
20
20
|
export interface AchievementContextType {
|
|
21
|
-
updateMetrics: (newMetrics:
|
|
21
|
+
updateMetrics: (newMetrics: AchievementMetrics | ((prevMetrics: AchievementMetrics) => AchievementMetrics)) => void;
|
|
22
22
|
unlockedAchievements: string[];
|
|
23
23
|
resetStorage: () => void;
|
|
24
24
|
}
|
|
@@ -33,165 +33,170 @@ export const useAchievementContext = () => {
|
|
|
33
33
|
return context;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
|
|
56
|
+
const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
57
|
+
children,
|
|
58
|
+
config,
|
|
59
|
+
initialState = {},
|
|
60
|
+
storageKey = 'react-achievements',
|
|
61
|
+
badgesButtonPosition = 'top-right',
|
|
62
|
+
styles = {},
|
|
63
|
+
icons = {},
|
|
64
|
+
}) => {
|
|
45
65
|
const dispatch: AppDispatch = useDispatch();
|
|
66
|
+
const configRef = useRef(config);
|
|
46
67
|
const metrics = useSelector((state: RootState) => state.achievements.metrics);
|
|
47
68
|
const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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
|
-
);
|
|
64
|
-
|
|
65
|
-
const resetStorage = useCallback(() => {
|
|
66
|
-
localStorage.removeItem(storageKey);
|
|
67
|
-
dispatch(resetAchievements());
|
|
68
|
-
}, [dispatch, storageKey]);
|
|
69
|
+
const pendingNotifications = useSelector((state: RootState) => state.achievements.pendingNotifications);
|
|
70
|
+
const [showBadges, setShowBadges] = useState(false);
|
|
71
|
+
const [showConfetti, setShowConfetti] = useState(false);
|
|
69
72
|
|
|
73
|
+
// Update config ref when it changes
|
|
70
74
|
useEffect(() => {
|
|
71
|
-
|
|
72
|
-
}, [
|
|
75
|
+
configRef.current = config;
|
|
76
|
+
}, [config]);
|
|
73
77
|
|
|
74
78
|
const checkAchievements = useCallback(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
Object.entries(configRef.current).forEach(([metricName, conditions]) => {
|
|
80
|
+
const metricValues = metrics[metricName];
|
|
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
|
+
|
|
92
|
+
conditions.forEach((condition) => {
|
|
93
|
+
if (
|
|
94
|
+
condition.isConditionMet(latestValue, state) &&
|
|
95
|
+
!unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
|
|
96
|
+
) {
|
|
97
|
+
dispatch(unlockAchievement(condition.achievementDetails));
|
|
98
|
+
setShowConfetti(true);
|
|
88
99
|
}
|
|
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
100
|
});
|
|
101
|
+
});
|
|
102
|
+
}, [metrics, unlockedAchievementIds, dispatch]);
|
|
108
103
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
// Handle notifications
|
|
105
|
+
useEffect(() => {
|
|
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
|
+
);
|
|
113
128
|
});
|
|
114
|
-
|
|
129
|
+
dispatch(clearNotifications());
|
|
115
130
|
}
|
|
116
|
-
}, [
|
|
131
|
+
}, [pendingNotifications, dispatch, icons]);
|
|
117
132
|
|
|
133
|
+
// Reset confetti after delay
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (showConfetti) {
|
|
136
|
+
const timer = setTimeout(() => setShowConfetti(false), 5000);
|
|
137
|
+
return () => clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}, [showConfetti]);
|
|
140
|
+
|
|
141
|
+
// Check for achievements when metrics change
|
|
118
142
|
useEffect(() => {
|
|
119
143
|
checkAchievements();
|
|
120
144
|
}, [metrics, checkAchievements]);
|
|
121
145
|
|
|
146
|
+
// Initialize on mount, but don't store config in Redux
|
|
122
147
|
useEffect(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.
|
|
135
|
-
.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
|
|
142
|
-
const previouslyAwardedAchievementsDetails = getAchievements(previouslyAwardedAchievementIds);
|
|
148
|
+
dispatch(initialize({
|
|
149
|
+
initialState,
|
|
150
|
+
storageKey,
|
|
151
|
+
}));
|
|
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]);
|
|
143
165
|
|
|
144
166
|
return (
|
|
145
|
-
<AchievementContext.Provider value={{
|
|
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
|
+
}}>
|
|
146
182
|
{children}
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
}}
|
|
156
|
-
styles={mergedStyles.achievementModal}
|
|
157
|
-
icons={mergedIcons}
|
|
183
|
+
<ToastContainer />
|
|
184
|
+
<ConfettiWrapper show={showConfetti} />
|
|
185
|
+
<BadgesButton
|
|
186
|
+
onClick={() => setShowBadges(true)}
|
|
187
|
+
position={badgesButtonPosition}
|
|
188
|
+
styles={styles.badgesButton || defaultStyles.badgesButton}
|
|
189
|
+
unlockedAchievements={achievementDetails}
|
|
158
190
|
/>
|
|
159
191
|
<BadgesModal
|
|
160
192
|
isOpen={showBadges}
|
|
161
|
-
achievements={
|
|
193
|
+
achievements={achievementDetails}
|
|
162
194
|
onClose={() => setShowBadges(false)}
|
|
163
|
-
styles={
|
|
164
|
-
icons={
|
|
195
|
+
styles={styles.badgesModal || defaultStyles.badgesModal}
|
|
196
|
+
icons={icons}
|
|
165
197
|
/>
|
|
166
|
-
<BadgesButton
|
|
167
|
-
onClick={showBadgesModal}
|
|
168
|
-
position={badgesButtonPosition}
|
|
169
|
-
styles={mergedStyles.badgesButton}
|
|
170
|
-
unlockedAchievements={previouslyAwardedAchievementsDetails}
|
|
171
|
-
/>
|
|
172
|
-
<ConfettiWrapper show={showConfetti || notifications.length > 0} />
|
|
173
198
|
</AchievementContext.Provider>
|
|
174
199
|
);
|
|
175
200
|
};
|
|
176
201
|
|
|
177
|
-
|
|
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
|
-
}
|
|
202
|
+
export { AchievementProvider };
|