react-achievements 1.3.7 → 2.0.3

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.
Files changed (90) hide show
  1. package/README.md +181 -146
  2. package/coverage/clover.xml +1 -1
  3. package/coverage/lcov-report/src/context/AchievementContext.tsx.html +6 -6
  4. package/coverage/lcov-report/src/context/index.html +1 -1
  5. package/coverage/lcov-report/src/index.ts.html +1 -1
  6. package/coverage/lcov.info +1 -1
  7. package/dist/assets/defaultIcons.d.ts +81 -0
  8. package/dist/components/AchievementModal.d.ts +3 -2
  9. package/dist/components/BadgesButton.d.ts +6 -1
  10. package/dist/components/BadgesModal.d.ts +3 -2
  11. package/dist/hooks/useAchievement.d.ts +8 -0
  12. package/dist/index.cjs.js +2100 -103
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.esm.js +2097 -120
  15. package/dist/providers/AchievementProvider.d.ts +11 -0
  16. package/dist/redux/achievementSlice.d.ts +25 -0
  17. package/dist/redux/notificationSlice.d.ts +7 -0
  18. package/dist/redux/store.d.ts +15 -0
  19. package/dist/types.d.ts +22 -13
  20. package/package.json +26 -18
  21. package/src/assets/defaultIcons.ts +100 -0
  22. package/src/components/AchievementModal.tsx +37 -8
  23. package/src/components/BadgesButton.tsx +33 -7
  24. package/src/components/BadgesModal.tsx +23 -9
  25. package/src/hooks/useAchievement.ts +20 -0
  26. package/src/index.ts +11 -7
  27. package/src/providers/AchievementProvider.tsx +185 -0
  28. package/src/redux/achievementSlice.ts +71 -0
  29. package/src/redux/notificationSlice.ts +26 -0
  30. package/src/redux/store.ts +20 -0
  31. package/src/types.ts +24 -13
  32. package/demo/src/stories/Button.jsx +0 -50
  33. package/demo/src/stories/Button.stories.js +0 -48
  34. package/demo/src/stories/Configure.mdx +0 -364
  35. package/demo/src/stories/Header.jsx +0 -59
  36. package/demo/src/stories/Header.stories.js +0 -28
  37. package/demo/src/stories/Page.jsx +0 -69
  38. package/demo/src/stories/Page.stories.js +0 -28
  39. package/demo/src/stories/assets/accessibility.png +0 -0
  40. package/demo/src/stories/assets/accessibility.svg +0 -1
  41. package/demo/src/stories/assets/addon-library.png +0 -0
  42. package/demo/src/stories/assets/assets.png +0 -0
  43. package/demo/src/stories/assets/avif-test-image.avif +0 -0
  44. package/demo/src/stories/assets/context.png +0 -0
  45. package/demo/src/stories/assets/discord.svg +0 -1
  46. package/demo/src/stories/assets/docs.png +0 -0
  47. package/demo/src/stories/assets/figma-plugin.png +0 -0
  48. package/demo/src/stories/assets/github.svg +0 -1
  49. package/demo/src/stories/assets/share.png +0 -0
  50. package/demo/src/stories/assets/styling.png +0 -0
  51. package/demo/src/stories/assets/testing.png +0 -0
  52. package/demo/src/stories/assets/theming.png +0 -0
  53. package/demo/src/stories/assets/tutorials.svg +0 -1
  54. package/demo/src/stories/assets/youtube.svg +0 -1
  55. package/demo/src/stories/button.css +0 -30
  56. package/demo/src/stories/header.css +0 -32
  57. package/demo/src/stories/page.css +0 -69
  58. package/dist/stories/Button.d.ts +0 -28
  59. package/dist/stories/Button.stories.d.ts +0 -23
  60. package/dist/stories/Header.d.ts +0 -13
  61. package/dist/stories/Header.stories.d.ts +0 -18
  62. package/dist/stories/Page.d.ts +0 -3
  63. package/dist/stories/Page.stories.d.ts +0 -12
  64. package/src/context/AchievementContext.tsx +0 -185
  65. package/src/stories/Button.stories.ts +0 -52
  66. package/src/stories/Button.tsx +0 -48
  67. package/src/stories/Configure.mdx +0 -364
  68. package/src/stories/Header.stories.ts +0 -33
  69. package/src/stories/Header.tsx +0 -56
  70. package/src/stories/Page.stories.ts +0 -32
  71. package/src/stories/Page.tsx +0 -73
  72. package/src/stories/assets/accessibility.png +0 -0
  73. package/src/stories/assets/accessibility.svg +0 -1
  74. package/src/stories/assets/addon-library.png +0 -0
  75. package/src/stories/assets/assets.png +0 -0
  76. package/src/stories/assets/avif-test-image.avif +0 -0
  77. package/src/stories/assets/context.png +0 -0
  78. package/src/stories/assets/discord.svg +0 -1
  79. package/src/stories/assets/docs.png +0 -0
  80. package/src/stories/assets/figma-plugin.png +0 -0
  81. package/src/stories/assets/github.svg +0 -1
  82. package/src/stories/assets/share.png +0 -0
  83. package/src/stories/assets/styling.png +0 -0
  84. package/src/stories/assets/testing.png +0 -0
  85. package/src/stories/assets/theming.png +0 -0
  86. package/src/stories/assets/tutorials.svg +0 -1
  87. package/src/stories/assets/youtube.svg +0 -1
  88. package/src/stories/button.css +0 -30
  89. package/src/stories/header.css +0 -32
  90. package/src/stories/page.css +0 -69
@@ -0,0 +1,185 @@
1
+ import React, { useEffect, useCallback } from 'react';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import { RootState, AppDispatch } from '../redux/store';
4
+ import { initialize, setMetrics, unlockAchievement, resetAchievements } from '../redux/achievementSlice';
5
+ import { addNotification, clearNotifications } from '../redux/notificationSlice';
6
+ import {
7
+ AchievementDetails,
8
+ AchievementMetricValue,
9
+ AchievementUnlockCondition,
10
+ AchievementProviderProps,
11
+ AchievementMetrics as AchievementMetricsType,
12
+ } from '../types';
13
+ import { defaultStyles, Styles } from '../defaultStyles';
14
+ import AchievementModal from '../components/AchievementModal';
15
+ import BadgesModal from '../components/BadgesModal';
16
+ import BadgesButton from '../components/BadgesButton';
17
+ import ConfettiWrapper from '../components/ConfettiWrapper';
18
+ import { defaultAchievementIcons } from '../assets/defaultIcons';
19
+
20
+ export interface AchievementContextType {
21
+ updateMetrics: (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => void;
22
+ unlockedAchievements: string[];
23
+ resetStorage: () => void;
24
+ }
25
+
26
+ export const AchievementContext = React.createContext<AchievementContextType | undefined>(undefined);
27
+
28
+ export const useAchievementContext = () => {
29
+ const context = React.useContext(AchievementContext);
30
+ if (!context) {
31
+ throw new Error('useAchievementContext must be used within an AchievementProvider');
32
+ }
33
+ return context;
34
+ };
35
+
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) => {
45
+ const dispatch: AppDispatch = useDispatch();
46
+ const metrics = useSelector((state: RootState) => state.achievements.metrics);
47
+ const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
48
+ 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]);
56
+
57
+ const updateMetrics = useCallback(
58
+ (newMetrics: AchievementMetricsType | ((prevMetrics: AchievementMetricsType) => AchievementMetricsType)) => {
59
+ dispatch(setMetrics(typeof newMetrics === 'function' ? newMetrics(metrics) : newMetrics));
60
+ },
61
+ [dispatch, metrics]
62
+ );
63
+
64
+ const resetStorage = useCallback(() => {
65
+ localStorage.removeItem(storageKey);
66
+ dispatch(resetAchievements());
67
+ }, [dispatch, storageKey]);
68
+
69
+ useEffect(() => {
70
+ dispatch(initialize({ config, initialState, storageKey }));
71
+ }, [dispatch, config, initialState, storageKey]);
72
+
73
+ const checkAchievements = useCallback(() => {
74
+ const newAchievements: AchievementDetails[] = [];
75
+
76
+ if (!unlockedAchievementIds) {
77
+ console.error('unlockedAchievements is undefined!');
78
+ return;
79
+ }
80
+
81
+ Object.entries(config).forEach(([metricName, conditions]) => {
82
+ const metricValues = metrics[metricName];
83
+
84
+ if (!metricValues) {
85
+ return;
86
+ }
87
+
88
+ conditions.forEach((condition) => {
89
+ if (
90
+ metricValues.some((value: AchievementMetricValue) => condition.isConditionMet(value)) &&
91
+ !unlockedAchievementIds.includes(condition.achievementDetails.achievementId)
92
+ ) {
93
+ newAchievements.push(condition.achievementDetails);
94
+ }
95
+ });
96
+ });
97
+
98
+ if (newAchievements.length > 0) {
99
+ newAchievements.forEach((achievement) => {
100
+ dispatch(unlockAchievement(achievement.achievementId));
101
+ dispatch(addNotification(achievement));
102
+ });
103
+ setShowConfetti(true);
104
+ }
105
+ }, [config, metrics, unlockedAchievementIds, dispatch]);
106
+
107
+ useEffect(() => {
108
+ checkAchievements();
109
+ }, [metrics, checkAchievements]);
110
+
111
+ useEffect(() => {
112
+ if (notifications.length > 0 && !currentAchievement) {
113
+ setCurrentAchievement(notifications[0]);
114
+ }
115
+ }, [notifications, currentAchievement]);
116
+
117
+ const showBadgesModal = () => setShowBadges(true);
118
+
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);
131
+
132
+ return (
133
+ <AchievementContext.Provider value={{ updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage }}>
134
+ {children}
135
+ <AchievementModal
136
+ isOpen={!!currentAchievement}
137
+ achievement={currentAchievement}
138
+ onClose={() => {
139
+ setCurrentAchievement(null);
140
+ if (currentAchievement) {
141
+ dispatch(clearNotifications());
142
+ }
143
+ }}
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}
153
+ />
154
+ <BadgesButton
155
+ onClick={showBadgesModal}
156
+ position={badgesButtonPosition}
157
+ styles={mergedStyles.badgesButton}
158
+ unlockedAchievements={unlockedAchievementsDetails}
159
+ />
160
+ <ConfettiWrapper show={showConfetti || notifications.length > 0} />
161
+ </AchievementContext.Provider>
162
+ );
163
+ };
164
+
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
+ }
@@ -0,0 +1,71 @@
1
+ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
+ import {
3
+ AchievementConfiguration,
4
+ InitialAchievementMetrics,
5
+ AchievementMetrics,
6
+ } from '../types';
7
+
8
+ export interface AchievementState {
9
+ config: AchievementConfiguration;
10
+ metrics: AchievementMetrics;
11
+ unlockedAchievements: string[];
12
+ storageKey: string | null;
13
+ }
14
+
15
+ const initialState: AchievementState = {
16
+ config: {},
17
+ metrics: {},
18
+ unlockedAchievements: [],
19
+ storageKey: null,
20
+ };
21
+
22
+ export const achievementSlice = createSlice({
23
+ name: 'achievements',
24
+ initialState,
25
+ reducers: {
26
+ initialize: (state, action: PayloadAction<{ config: AchievementConfiguration; initialState?: InitialAchievementMetrics; storageKey: string }>) => {
27
+ state.config = action.payload.config;
28
+ state.storageKey = action.payload.storageKey;
29
+ const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
30
+ if (storedState) {
31
+ try {
32
+ const parsedState = JSON.parse(storedState);
33
+ state.metrics = parsedState.achievements?.metrics || {};
34
+ state.unlockedAchievements = parsedState.achievements?.unlockedAchievements || [];
35
+ } catch (error) {
36
+ 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]] }), {}) : {};
38
+ state.unlockedAchievements = [];
39
+ }
40
+ } 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]] }), {}) : {};
42
+ state.unlockedAchievements = [];
43
+ }
44
+ },
45
+ setMetrics: (state, action: PayloadAction<AchievementMetrics>) => {
46
+ state.metrics = action.payload;
47
+ if (state.storageKey) {
48
+ localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
49
+ }
50
+ },
51
+ unlockAchievement: (state, action: PayloadAction<string>) => {
52
+ if (!state.unlockedAchievements.includes(action.payload)) {
53
+ state.unlockedAchievements.push(action.payload);
54
+ if (state.storageKey) {
55
+ localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
56
+ }
57
+ }
58
+ },
59
+ resetAchievements: (state) => {
60
+ state.metrics = {};
61
+ state.unlockedAchievements = [];
62
+ if (state.storageKey) {
63
+ localStorage.removeItem(state.storageKey);
64
+ }
65
+ },
66
+ },
67
+ });
68
+
69
+ export const { initialize, setMetrics, unlockAchievement, resetAchievements } = achievementSlice.actions;
70
+
71
+ export default achievementSlice.reducer;
@@ -0,0 +1,26 @@
1
+ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2
+ import { AchievementDetails } from '../types';
3
+
4
+ export interface NotificationState {
5
+ notifications: AchievementDetails[];
6
+ }
7
+
8
+ const initialState: NotificationState = {
9
+ notifications: [],
10
+ };
11
+
12
+ const notificationSlice = createSlice({
13
+ name: 'notifications',
14
+ initialState,
15
+ reducers: {
16
+ addNotification: (state, action: PayloadAction<AchievementDetails>) => {
17
+ state.notifications.push(action.payload);
18
+ },
19
+ clearNotifications: (state) => {
20
+ state.notifications = [];
21
+ },
22
+ },
23
+ });
24
+
25
+ export const { addNotification, clearNotifications } = notificationSlice.actions;
26
+ export default notificationSlice.reducer;
@@ -0,0 +1,20 @@
1
+ import { configureStore } from '@reduxjs/toolkit';
2
+ import achievementReducer from '../redux/achievementSlice';
3
+ import notificationReducer from '../redux/notificationSlice';
4
+ import { AchievementState } from './achievementSlice';
5
+ import { NotificationState } from './notificationSlice';
6
+
7
+ export interface RootState {
8
+ achievements: AchievementState;
9
+ notifications: NotificationState;
10
+ }
11
+
12
+ const store = configureStore({
13
+ reducer: {
14
+ achievements: achievementReducer,
15
+ notifications: notificationReducer,
16
+ },
17
+ });
18
+
19
+ export type AppDispatch = typeof store.dispatch;
20
+ export default store;
package/src/types.ts CHANGED
@@ -1,21 +1,32 @@
1
- export interface AchievementData {
2
- id: string;
3
- title: string;
4
- description: string;
5
- icon: string;
1
+ export type AchievementMetricValue = number | string | boolean | Date;
2
+
3
+ export interface AchievementDetails {
4
+ achievementId: string;
5
+ achievementTitle: string;
6
+ achievementDescription: string;
7
+ achievementIconKey?: string;
6
8
  }
7
9
 
8
- export type MetricValue = number | string | boolean | Date;
10
+ export type AchievementIconRecord = Record<string, string>;
9
11
 
10
- export interface Metrics {
11
- [key: string]: MetricValue[];
12
+ export interface AchievementConfiguration {
13
+ [metricName: string]: Array<AchievementUnlockCondition<AchievementMetricValue>>;
12
14
  }
13
15
 
14
- export interface AchievementCondition {
15
- check: (value: MetricValue[]) => boolean;
16
- data: AchievementData;
16
+ export type InitialAchievementMetrics = Record<string, AchievementMetricValue | AchievementMetricValue[] | undefined>;
17
+ export type AchievementMetrics = Record<string, AchievementMetricValue[]>;
18
+
19
+ export interface AchievementProviderProps {
20
+ children: React.ReactNode;
21
+ config: AchievementConfiguration;
22
+ initialState?: InitialAchievementMetrics;
23
+ storageKey?: string;
24
+ badgesButtonPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
25
+ styles?: Partial<import('./defaultStyles').Styles>;
26
+ icons?: Record<string, string>;
17
27
  }
18
28
 
19
- export interface AchievementConfig {
20
- [key: string]: AchievementCondition[];
29
+ export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
30
+ isConditionMet: (value: T) => boolean;
31
+ achievementDetails: AchievementDetails;
21
32
  }
@@ -1,50 +0,0 @@
1
- import React from 'react';
2
- import PropTypes from 'prop-types';
3
- import './button.css';
4
-
5
- /**
6
- * Primary UI component for user interaction
7
- */
8
- export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
9
- const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
10
- return (
11
- <button
12
- type="button"
13
- className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
14
- style={backgroundColor && { backgroundColor }}
15
- {...props}
16
- >
17
- {label}
18
- </button>
19
- );
20
- };
21
-
22
- Button.propTypes = {
23
- /**
24
- * Is this the principal call to action on the page?
25
- */
26
- primary: PropTypes.bool,
27
- /**
28
- * What background color to use
29
- */
30
- backgroundColor: PropTypes.string,
31
- /**
32
- * How large should the button be?
33
- */
34
- size: PropTypes.oneOf(['small', 'medium', 'large']),
35
- /**
36
- * Button contents
37
- */
38
- label: PropTypes.string.isRequired,
39
- /**
40
- * Optional click handler
41
- */
42
- onClick: PropTypes.func,
43
- };
44
-
45
- Button.defaultProps = {
46
- backgroundColor: null,
47
- primary: false,
48
- size: 'medium',
49
- onClick: undefined,
50
- };
@@ -1,48 +0,0 @@
1
- import { fn } from '@storybook/test';
2
- import { Button } from './Button';
3
-
4
- // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
5
- export default {
6
- title: 'Example/Button',
7
- component: Button,
8
- parameters: {
9
- // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
10
- layout: 'centered',
11
- },
12
- // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
13
- tags: ['autodocs'],
14
- // More on argTypes: https://storybook.js.org/docs/api/argtypes
15
- argTypes: {
16
- backgroundColor: { control: 'color' },
17
- },
18
- // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
19
- args: { onClick: fn() },
20
- };
21
-
22
- // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
23
- export const Primary = {
24
- args: {
25
- primary: true,
26
- label: 'Button',
27
- },
28
- };
29
-
30
- export const Secondary = {
31
- args: {
32
- label: 'Button',
33
- },
34
- };
35
-
36
- export const Large = {
37
- args: {
38
- size: 'large',
39
- label: 'Button',
40
- },
41
- };
42
-
43
- export const Small = {
44
- args: {
45
- size: 'small',
46
- label: 'Button',
47
- },
48
- };