react-achievements 1.2.0 → 1.3.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,5 +1,6 @@
1
1
  import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
2
2
  import { Metrics, AchievementConfig, AchievementData, MetricValue } from '../types';
3
+ import { defaultStyles, Styles } from '../defaultStyles';
3
4
  import AchievementModal from '../components/AchievementModal';
4
5
  import BadgesModal from '../components/BadgesModal';
5
6
  import BadgesButton from '../components/BadgesButton';
@@ -8,7 +9,7 @@ import ConfettiWrapper from '../components/ConfettiWrapper';
8
9
  interface AchievementContextProps {
9
10
  metrics: Metrics;
10
11
  setMetrics: (metrics: Metrics | ((prevMetrics: Metrics) => Metrics)) => void;
11
- achievedAchievements: string[];
12
+ unlockedAchievements: string[];
12
13
  checkAchievements: () => void;
13
14
  showBadgesModal: () => void;
14
15
  }
@@ -19,6 +20,7 @@ interface AchievementProviderProps {
19
20
  initialState?: Record<string, MetricValue>;
20
21
  storageKey?: string;
21
22
  badgesButtonPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
23
+ styles?: Partial<Styles>;
22
24
  }
23
25
 
24
26
  const AchievementContext = createContext<AchievementContextProps | undefined>(undefined);
@@ -28,29 +30,23 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
28
30
  config,
29
31
  initialState = {},
30
32
  storageKey = 'react-achievements',
31
- badgesButtonPosition = 'top-right'
33
+ badgesButtonPosition = 'top-right',
34
+ styles = {},
32
35
  }) => {
33
- const extractMetrics = (state: Record<string, MetricValue>): Metrics => {
34
- return Object.keys(config).reduce((acc, key) => {
35
- if (key in state) {
36
- acc[key] = state[key];
37
- } else {
38
- acc[key] = [];
39
- }
40
- return acc;
41
- }, {} as Metrics);
42
- };
36
+ const mergedStyles = React.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
43
37
 
44
38
  const [metrics, setMetrics] = useState<Metrics>(() => {
45
39
  const savedMetrics = localStorage.getItem(`${storageKey}-metrics`);
46
- if (savedMetrics) {
47
- return JSON.parse(savedMetrics);
48
- }
49
- return extractMetrics(initialState);
40
+ return savedMetrics ? JSON.parse(savedMetrics) : initialState;
50
41
  });
51
42
 
52
- const [achievedAchievements, setAchievedAchievements] = useState<string[]>(() => {
53
- const saved = localStorage.getItem(`${storageKey}-achievements`);
43
+ const [unlockedAchievements, setUnlockedAchievements] = useState<string[]>(() => {
44
+ const saved = localStorage.getItem(`${storageKey}-unlocked-achievements`);
45
+ return saved ? JSON.parse(saved) : [];
46
+ });
47
+
48
+ const [newlyUnlockedAchievements, setNewlyUnlockedAchievements] = useState<string[]>(() => {
49
+ const saved = localStorage.getItem(`${storageKey}-newly-unlocked-achievements`);
54
50
  return saved ? JSON.parse(saved) : [];
55
51
  });
56
52
 
@@ -65,20 +61,28 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
65
61
  Object.entries(config).forEach(([metricKey, conditions]) => {
66
62
  const metricValue = metrics[metricKey];
67
63
  conditions.forEach(condition => {
68
- if (condition.check(metricValue) && !achievedAchievements.includes(condition.data.id)) {
64
+ if (condition.check(metricValue) && !unlockedAchievements.includes(condition.data.id)) {
69
65
  newAchievements.push(condition.data);
70
66
  }
71
67
  });
72
68
  });
73
69
 
74
70
  if (newAchievements.length > 0) {
75
- const updatedAchievements = [...achievedAchievements, ...newAchievements.map(a => a.id)];
76
- setAchievedAchievements(updatedAchievements);
77
- localStorage.setItem(`${storageKey}-achievements`, JSON.stringify(updatedAchievements));
71
+ const newlyUnlockedIds = newAchievements.map(a => a.id);
72
+ setUnlockedAchievements(prev => {
73
+ const updated = [...prev, ...newlyUnlockedIds];
74
+ localStorage.setItem(`${storageKey}-unlocked-achievements`, JSON.stringify(updated));
75
+ return updated;
76
+ });
77
+ setNewlyUnlockedAchievements(prev => {
78
+ const updated = [...prev, ...newlyUnlockedIds];
79
+ localStorage.setItem(`${storageKey}-newly-unlocked-achievements`, JSON.stringify(updated));
80
+ return updated;
81
+ });
78
82
  setAchievementQueue(prevQueue => [...prevQueue, ...newAchievements]);
79
83
  setShowConfetti(true);
80
84
  }
81
- }, [config, metrics, achievedAchievements, storageKey]);
85
+ }, [config, metrics, unlockedAchievements, storageKey]);
82
86
 
83
87
  useEffect(() => {
84
88
  checkAchievements();
@@ -86,14 +90,19 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
86
90
 
87
91
  useEffect(() => {
88
92
  if (achievementQueue.length > 0 && !currentAchievement) {
89
- setCurrentAchievement(achievementQueue[0]);
93
+ const nextAchievement = achievementQueue[0];
94
+ setCurrentAchievement(nextAchievement);
90
95
  setAchievementQueue(prevQueue => prevQueue.slice(1));
96
+
97
+ setNewlyUnlockedAchievements(prev => {
98
+ const updated = prev.filter(id => id !== nextAchievement.id);
99
+ localStorage.setItem(`${storageKey}-newly-unlocked-achievements`, JSON.stringify(updated));
100
+ return updated;
101
+ });
91
102
  }
92
- }, [achievementQueue, currentAchievement]);
103
+ }, [achievementQueue, currentAchievement, storageKey]);
93
104
 
94
- const showBadgesModal = () => {
95
- setShowBadges(true);
96
- };
105
+ const showBadgesModal = () => setShowBadges(true);
97
106
 
98
107
  const getAchievements = (achievedIds: string[]) => {
99
108
  return Object.values(config).flatMap(conditions =>
@@ -110,7 +119,7 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
110
119
  return updatedMetrics;
111
120
  });
112
121
  },
113
- achievedAchievements,
122
+ unlockedAchievements,
114
123
  checkAchievements,
115
124
  showBadgesModal
116
125
  };
@@ -119,21 +128,27 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
119
128
  <AchievementContext.Provider value={contextValue}>
120
129
  {children}
121
130
  <AchievementModal
122
- show={!!currentAchievement}
131
+ isOpen={!!currentAchievement}
123
132
  achievement={currentAchievement}
124
133
  onClose={() => {
125
134
  setCurrentAchievement(null);
126
- if (achievementQueue.length === 0) {
135
+ if (achievementQueue.length === 0 && newlyUnlockedAchievements.length === 0) {
127
136
  setShowConfetti(false);
128
137
  }
129
138
  }}
139
+ styles={mergedStyles.achievementModal}
130
140
  />
131
141
  <BadgesModal
132
- show={showBadges}
133
- achievements={getAchievements(achievedAchievements)}
142
+ isOpen={showBadges}
143
+ achievements={getAchievements(unlockedAchievements)}
134
144
  onClose={() => setShowBadges(false)}
145
+ styles={mergedStyles.badgesModal}
146
+ />
147
+ <BadgesButton
148
+ onClick={showBadgesModal}
149
+ position={badgesButtonPosition}
150
+ styles={mergedStyles.badgesButton}
135
151
  />
136
- <BadgesButton onClick={showBadgesModal} position={badgesButtonPosition} />
137
152
  <ConfettiWrapper show={showConfetti || achievementQueue.length > 0} />
138
153
  </AchievementContext.Provider>
139
154
  );
@@ -145,4 +160,26 @@ export const useAchievement = () => {
145
160
  throw new Error('useAchievement must be used within an AchievementProvider');
146
161
  }
147
162
  return context;
148
- };
163
+ };
164
+
165
+ // Helper function to deep merge objects
166
+ function mergeDeep(target: any, source: any) {
167
+ const output = Object.assign({}, target);
168
+ if (isObject(target) && isObject(source)) {
169
+ Object.keys(source).forEach(key => {
170
+ if (isObject(source[key])) {
171
+ if (!(key in target))
172
+ Object.assign(output, { [key]: source[key] });
173
+ else
174
+ output[key] = mergeDeep(target[key], source[key]);
175
+ } else {
176
+ Object.assign(output, { [key]: source[key] });
177
+ }
178
+ });
179
+ }
180
+ return output;
181
+ }
182
+
183
+ function isObject(item: any) {
184
+ return (item && typeof item === 'object' && !Array.isArray(item));
185
+ }
@@ -0,0 +1,138 @@
1
+ type StyleObject = { [key: string]: string | number };
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
+ interface BadgesModalStyles {
13
+ overlay: StyleObject;
14
+ content: StyleObject;
15
+ title: StyleObject;
16
+ badgeContainer: StyleObject;
17
+ badge: StyleObject;
18
+ badgeIcon: StyleObject;
19
+ badgeTitle: StyleObject;
20
+ button: StyleObject;
21
+ }
22
+
23
+ export interface Styles {
24
+ achievementModal: ModalStyles;
25
+ badgesModal: BadgesModalStyles;
26
+ badgesButton: StyleObject;
27
+ }
28
+
29
+ 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
+ badgesModal: {
73
+ overlay: {
74
+ position: 'fixed',
75
+ top: 0,
76
+ left: 0,
77
+ right: 0,
78
+ bottom: 0,
79
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
80
+ display: 'flex',
81
+ alignItems: 'center',
82
+ justifyContent: 'center',
83
+ },
84
+ content: {
85
+ backgroundColor: '#ffffff',
86
+ borderRadius: '8px',
87
+ padding: '20px',
88
+ maxWidth: '600px',
89
+ width: '100%',
90
+ maxHeight: '80vh',
91
+ overflowY: 'auto',
92
+ },
93
+ title: {
94
+ fontSize: '24px',
95
+ fontWeight: 'bold',
96
+ marginBottom: '20px',
97
+ },
98
+ badgeContainer: {
99
+ display: 'flex',
100
+ flexWrap: 'wrap',
101
+ justifyContent: 'center',
102
+ },
103
+ badge: {
104
+ display: 'flex',
105
+ flexDirection: 'column',
106
+ alignItems: 'center',
107
+ margin: '10px',
108
+ },
109
+ badgeIcon: {
110
+ width: '50px',
111
+ height: '50px',
112
+ marginBottom: '5px',
113
+ },
114
+ badgeTitle: {
115
+ fontSize: '14px',
116
+ textAlign: 'center',
117
+ },
118
+ button: {
119
+ backgroundColor: '#007bff',
120
+ color: '#ffffff',
121
+ padding: '10px 20px',
122
+ borderRadius: '4px',
123
+ border: 'none',
124
+ cursor: 'pointer',
125
+ marginTop: '20px',
126
+ },
127
+ },
128
+ badgesButton: {
129
+ position: 'fixed',
130
+ padding: '10px 20px',
131
+ backgroundColor: '#007bff',
132
+ color: '#ffffff',
133
+ border: 'none',
134
+ borderRadius: '4px',
135
+ cursor: 'pointer',
136
+ zIndex: 1000,
137
+ },
138
+ };
package/src/types.ts CHANGED
@@ -1,11 +1,3 @@
1
- export type MetricValueItem = number | boolean | string | any;
2
-
3
- export type MetricValue = MetricValueItem[];
4
-
5
- export interface Metrics {
6
- [key: string]: MetricValue;
7
- }
8
-
9
1
  export interface AchievementData {
10
2
  id: string;
11
3
  title: string;
@@ -13,11 +5,17 @@ export interface AchievementData {
13
5
  icon: string;
14
6
  }
15
7
 
8
+ export type MetricValue = number | string | boolean | Date;
9
+
10
+ export interface Metrics {
11
+ [key: string]: MetricValue[];
12
+ }
13
+
16
14
  export interface AchievementCondition {
17
- check: (metricValue: MetricValue) => boolean;
15
+ check: (value: MetricValue[]) => boolean;
18
16
  data: AchievementData;
19
17
  }
20
18
 
21
19
  export interface AchievementConfig {
22
- [metricKey: string]: AchievementCondition[];
20
+ [key: string]: AchievementCondition[];
23
21
  }