react-achievements 2.0.5 → 2.1.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 +101 -2
- package/dist/hooks/useAchievementState.d.ts +4 -0
- package/dist/index.cjs.js +58 -19
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +58 -20
- package/dist/redux/achievementSlice.d.ts +9 -3
- package/dist/types.d.ts +5 -2
- package/package.json +1 -1
- package/src/hooks/useAchievementState.ts +12 -0
- package/src/index.ts +2 -0
- package/src/providers/AchievementProvider.tsx +34 -22
- package/src/redux/achievementSlice.ts +29 -7
- package/src/types.ts +4 -2
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ const initialState = {
|
|
|
43
43
|
experience: 0,
|
|
44
44
|
monstersDefeated: 0,
|
|
45
45
|
questsCompleted: 0,
|
|
46
|
+
previouslyAwardedAchievements: ['first_step'], // Optional: Load previously awarded achievements
|
|
46
47
|
// Add any other initial metrics here
|
|
47
48
|
};
|
|
48
49
|
|
|
@@ -51,7 +52,7 @@ function App() {
|
|
|
51
52
|
<Provider store={store}>
|
|
52
53
|
<AchievementProvider
|
|
53
54
|
config={achievementConfig} // Required: your achievement configuration
|
|
54
|
-
initialState={initialState} // Required: initial game metrics. This can be loaded from your server
|
|
55
|
+
initialState={initialState} // Required: initial game metrics and optionally previously awarded achievements. This can be loaded from your server
|
|
55
56
|
storageKey="my-game-achievements" // Optional: customize local storage key
|
|
56
57
|
badgesButtonPosition="top-right" // Optional: customize badges button position
|
|
57
58
|
// Optional: add custom styles and icons here
|
|
@@ -231,6 +232,7 @@ export default Game;
|
|
|
231
232
|
- Achievement Gallery: Players can view all their unlocked achievements, encouraging completionism.
|
|
232
233
|
- Confetti Effect: A celebratory confetti effect is displayed when an achievement is unlocked, adding to the excitement.
|
|
233
234
|
- Local Storage: Achievements are stored locally on the device.
|
|
235
|
+
- **Loading Previous Awards:** The AchievementProvider accepts an optional previouslyAwardedAchievements array in its initialState prop, allowing you to load achievements that the user has already earned.
|
|
234
236
|
- **Programmatic Reset:** Includes a `resetStorage` function accessible via the `useAchievementContext` hook to easily reset all achievement data.
|
|
235
237
|
|
|
236
238
|
<h2 align="center">🔧 API</h2>
|
|
@@ -240,7 +242,7 @@ export default Game;
|
|
|
240
242
|
#### Props:
|
|
241
243
|
|
|
242
244
|
- `config`: An object defining your metrics and achievements.
|
|
243
|
-
- `initialState`: The initial state of your metrics.
|
|
245
|
+
- `initialState`: The initial state of your metrics. Can also include an optional previouslyAwardedAchievements array of achievement IDs.
|
|
244
246
|
- `storageKey` (optional): A string to use as the key for localStorage. Default: 'react-achievements'
|
|
245
247
|
- `badgesButtonPosition` (optional): Position of the badges button. Default: 'top-right'
|
|
246
248
|
- `styles` (optional): Custom styles for the achievement components.
|
|
@@ -253,6 +255,56 @@ export default Game;
|
|
|
253
255
|
- `unlockedAchievements`: Array of unlocked achievement IDs. (Note: Access the actual Redux state using `useSelector`).
|
|
254
256
|
- `resetStorage`: Function to clear all achievement data from local storage and reset the Redux state.
|
|
255
257
|
|
|
258
|
+
<h3 align="center">🪝 useAchievementState Hook</h3>
|
|
259
|
+
<h4 align="center">Returns an object containing the current achievement state, useful for saving to a server or other persistent storage.
|
|
260
|
+
</h4>
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
#### Returns an object with:
|
|
264
|
+
|
|
265
|
+
- `metrics`: The current achievement metrics object.
|
|
266
|
+
- `previouslyAwardedAchievements`: An array of achievement IDs that have been previously awarded to the user.
|
|
267
|
+
|
|
268
|
+
**Example Usage:**
|
|
269
|
+
|
|
270
|
+
```jsx
|
|
271
|
+
import React from 'react';
|
|
272
|
+
import { useAchievementState } from 'react-achievements';
|
|
273
|
+
|
|
274
|
+
const SyncAchievementsButton = () => {
|
|
275
|
+
const { metrics, previouslyAwardedAchievements } = useAchievementState();
|
|
276
|
+
|
|
277
|
+
const handleSaveToServer = async () => {
|
|
278
|
+
const achievementData = {
|
|
279
|
+
metrics,
|
|
280
|
+
previouslyAwardedAchievements,
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
const response = await fetch('/api/save-achievements', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: {
|
|
286
|
+
'Content-Type': 'application/json',
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify(achievementData),
|
|
289
|
+
});
|
|
290
|
+
if (response.ok) {
|
|
291
|
+
console.log('Achievement data saved successfully!');
|
|
292
|
+
} else {
|
|
293
|
+
console.error('Failed to save achievement data.');
|
|
294
|
+
}
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.error('Error saving achievement data:', error);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<button onClick={handleSaveToServer}>Save Achievements to Server</button>
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export default SyncAchievementsButton;
|
|
306
|
+
```
|
|
307
|
+
|
|
256
308
|
<h2 align="center">🎨 Customization</h2>
|
|
257
309
|
|
|
258
310
|
React-Achievements allows for extensive customization of its appearance. You can override the default styles by passing a `styles` prop to the `AchievementProvider`:
|
|
@@ -412,6 +464,53 @@ The achievements and metrics are managed by Redux and persisted in local storage
|
|
|
412
464
|
}
|
|
413
465
|
```
|
|
414
466
|
|
|
467
|
+
<h2 align="center">💾 Saving and Loading Progress</h2>
|
|
468
|
+
|
|
469
|
+
<h4 align="center">To persist user achievement progress across sessions or devices, you'll typically want to save the `metrics` and `previouslyAwardedAchievements` from your Redux store to your server. You can use the `useAchievementState` hook to access this data and trigger the save operation, for example, when the user logs out:
|
|
470
|
+
</h4>
|
|
471
|
+
|
|
472
|
+
```jsx
|
|
473
|
+
import React from 'react';
|
|
474
|
+
import { useAchievementState } from 'react-achievements/hooks/useAchievementState';
|
|
475
|
+
|
|
476
|
+
const LogoutButtonWithSave = ({ onLogout }) => {
|
|
477
|
+
const { metrics, previouslyAwardedAchievements } = useAchievementState();
|
|
478
|
+
|
|
479
|
+
const handleLogoutAndSave = async () => {
|
|
480
|
+
const achievementData = {
|
|
481
|
+
metrics,
|
|
482
|
+
previouslyAwardedAchievements,
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
const response = await fetch('/api/save-achievements', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: {
|
|
488
|
+
'Content-Type': 'application/json',
|
|
489
|
+
// Include any necessary authentication headers
|
|
490
|
+
},
|
|
491
|
+
body: JSON.stringify(achievementData),
|
|
492
|
+
});
|
|
493
|
+
if (response.ok) {
|
|
494
|
+
console.log('Achievement data saved successfully before logout!');
|
|
495
|
+
} else {
|
|
496
|
+
console.error('Failed to save achievement data before logout.');
|
|
497
|
+
}
|
|
498
|
+
} catch (error) {
|
|
499
|
+
console.error('Error saving achievement data:', error);
|
|
500
|
+
} finally {
|
|
501
|
+
// Proceed with the logout action regardless of save success
|
|
502
|
+
onLogout();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<button onClick={handleLogoutAndSave}>Logout</button>
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
export default LogoutButtonWithSave;
|
|
512
|
+
```
|
|
513
|
+
|
|
415
514
|
<h2 align="center">🏆Available Icons🏆</h2>
|
|
416
515
|
|
|
417
516
|
```
|
package/dist/index.cjs.js
CHANGED
|
@@ -1832,10 +1832,12 @@ typeof queueMicrotask === "function" ? queueMicrotask.bind(typeof window !== "un
|
|
|
1832
1832
|
// src/index.ts
|
|
1833
1833
|
F();
|
|
1834
1834
|
|
|
1835
|
+
// src/redux/achievementSlice.ts
|
|
1835
1836
|
const initialState$1 = {
|
|
1836
1837
|
config: {},
|
|
1837
1838
|
metrics: {},
|
|
1838
1839
|
unlockedAchievements: [],
|
|
1840
|
+
previouslyAwardedAchievements: [], // Initialize as empty
|
|
1839
1841
|
storageKey: null,
|
|
1840
1842
|
};
|
|
1841
1843
|
const achievementSlice = createSlice({
|
|
@@ -1843,51 +1845,67 @@ const achievementSlice = createSlice({
|
|
|
1843
1845
|
initialState: initialState$1,
|
|
1844
1846
|
reducers: {
|
|
1845
1847
|
initialize: (state, action) => {
|
|
1846
|
-
var _a, _b;
|
|
1848
|
+
var _a, _b, _c, _d;
|
|
1847
1849
|
state.config = action.payload.config;
|
|
1848
1850
|
state.storageKey = action.payload.storageKey;
|
|
1849
1851
|
const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
|
|
1852
|
+
const initialMetrics = action.payload.initialState ? Object.keys(action.payload.initialState)
|
|
1853
|
+
.filter(key => key !== 'previouslyAwardedAchievements')
|
|
1854
|
+
.reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
|
|
1855
|
+
const initialAwarded = ((_a = action.payload.initialState) === null || _a === void 0 ? void 0 : _a.previouslyAwardedAchievements) || [];
|
|
1850
1856
|
if (storedState) {
|
|
1851
1857
|
try {
|
|
1852
1858
|
const parsedState = JSON.parse(storedState);
|
|
1853
|
-
state.metrics = ((
|
|
1854
|
-
state.unlockedAchievements = ((
|
|
1859
|
+
state.metrics = ((_b = parsedState.achievements) === null || _b === void 0 ? void 0 : _b.metrics) || initialMetrics;
|
|
1860
|
+
state.unlockedAchievements = ((_c = parsedState.achievements) === null || _c === void 0 ? void 0 : _c.unlockedAchievements) || [];
|
|
1861
|
+
state.previouslyAwardedAchievements = ((_d = parsedState.achievements) === null || _d === void 0 ? void 0 : _d.previouslyAwardedAchievements) || initialAwarded; // Prioritize stored, fallback to initial
|
|
1855
1862
|
}
|
|
1856
1863
|
catch (error) {
|
|
1857
1864
|
console.error('Error parsing stored achievement state:', error);
|
|
1858
|
-
state.metrics =
|
|
1865
|
+
state.metrics = initialMetrics;
|
|
1859
1866
|
state.unlockedAchievements = [];
|
|
1867
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
1860
1868
|
}
|
|
1861
1869
|
}
|
|
1862
1870
|
else {
|
|
1863
|
-
state.metrics =
|
|
1871
|
+
state.metrics = initialMetrics;
|
|
1864
1872
|
state.unlockedAchievements = [];
|
|
1873
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
1865
1874
|
}
|
|
1866
1875
|
},
|
|
1867
1876
|
setMetrics: (state, action) => {
|
|
1868
1877
|
state.metrics = action.payload;
|
|
1869
1878
|
if (state.storageKey) {
|
|
1870
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
1879
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1871
1880
|
}
|
|
1872
1881
|
},
|
|
1873
1882
|
unlockAchievement: (state, action) => {
|
|
1874
1883
|
if (!state.unlockedAchievements.includes(action.payload)) {
|
|
1875
1884
|
state.unlockedAchievements.push(action.payload);
|
|
1876
1885
|
if (state.storageKey) {
|
|
1877
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
1886
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
},
|
|
1890
|
+
markAchievementAsAwarded: (state, action) => {
|
|
1891
|
+
if (!state.previouslyAwardedAchievements.includes(action.payload)) {
|
|
1892
|
+
state.previouslyAwardedAchievements.push(action.payload);
|
|
1893
|
+
if (state.storageKey) {
|
|
1894
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1878
1895
|
}
|
|
1879
1896
|
}
|
|
1880
1897
|
},
|
|
1881
1898
|
resetAchievements: (state) => {
|
|
1882
1899
|
state.metrics = {};
|
|
1883
1900
|
state.unlockedAchievements = [];
|
|
1901
|
+
state.previouslyAwardedAchievements = [];
|
|
1884
1902
|
if (state.storageKey) {
|
|
1885
1903
|
localStorage.removeItem(state.storageKey);
|
|
1886
1904
|
}
|
|
1887
1905
|
},
|
|
1888
1906
|
},
|
|
1889
1907
|
});
|
|
1890
|
-
const { initialize, setMetrics, unlockAchievement, resetAchievements } = achievementSlice.actions;
|
|
1908
|
+
const { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } = achievementSlice.actions;
|
|
1891
1909
|
var achievementSlice$1 = achievementSlice.reducer;
|
|
1892
1910
|
|
|
1893
1911
|
const initialState = {
|
|
@@ -2262,6 +2280,7 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2262
2280
|
const dispatch = useDispatch();
|
|
2263
2281
|
const metrics = useSelector((state) => state.achievements.metrics);
|
|
2264
2282
|
const unlockedAchievementIds = useSelector((state) => state.achievements.unlockedAchievements);
|
|
2283
|
+
const previouslyAwardedAchievementIds = useSelector((state) => state.achievements.previouslyAwardedAchievements);
|
|
2265
2284
|
const notifications = useSelector((state) => state.notifications.notifications);
|
|
2266
2285
|
const mergedStyles = React.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
|
|
2267
2286
|
const [currentAchievement, setCurrentAchievement] = React.useState(null);
|
|
@@ -2279,31 +2298,40 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2279
2298
|
dispatch(initialize({ config, initialState, storageKey }));
|
|
2280
2299
|
}, [dispatch, config, initialState, storageKey]);
|
|
2281
2300
|
const checkAchievements = React.useCallback(() => {
|
|
2282
|
-
const
|
|
2301
|
+
const newAchievementsToAward = [];
|
|
2283
2302
|
if (!unlockedAchievementIds) {
|
|
2284
2303
|
console.error('unlockedAchievements is undefined!');
|
|
2285
2304
|
return;
|
|
2286
2305
|
}
|
|
2287
|
-
Object.entries(config)
|
|
2306
|
+
Object.entries(config)
|
|
2307
|
+
.forEach(([metricName, conditions]) => {
|
|
2288
2308
|
const metricValues = metrics[metricName];
|
|
2289
2309
|
if (!metricValues) {
|
|
2290
2310
|
return;
|
|
2291
2311
|
}
|
|
2292
|
-
conditions
|
|
2312
|
+
conditions
|
|
2313
|
+
.filter(condition => !previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId))
|
|
2314
|
+
.forEach((condition) => {
|
|
2293
2315
|
if (metricValues.some((value) => condition.isConditionMet(value)) &&
|
|
2294
2316
|
!unlockedAchievementIds.includes(condition.achievementDetails.achievementId)) {
|
|
2295
|
-
|
|
2317
|
+
dispatch(unlockAchievement(condition.achievementDetails.achievementId));
|
|
2318
|
+
newAchievementsToAward.push(condition.achievementDetails);
|
|
2319
|
+
}
|
|
2320
|
+
else if (metricValues.some((value) => condition.isConditionMet(value)) &&
|
|
2321
|
+
unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
|
|
2322
|
+
!previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId)) {
|
|
2323
|
+
newAchievementsToAward.push(condition.achievementDetails);
|
|
2296
2324
|
}
|
|
2297
2325
|
});
|
|
2298
2326
|
});
|
|
2299
|
-
if (
|
|
2300
|
-
|
|
2301
|
-
dispatch(unlockAchievement(achievement.achievementId));
|
|
2327
|
+
if (newAchievementsToAward.length > 0) {
|
|
2328
|
+
newAchievementsToAward.forEach((achievement) => {
|
|
2302
2329
|
dispatch(addNotification(achievement));
|
|
2330
|
+
dispatch(markAchievementAsAwarded(achievement.achievementId));
|
|
2303
2331
|
});
|
|
2304
2332
|
setShowConfetti(true);
|
|
2305
2333
|
}
|
|
2306
|
-
}, [config, metrics, unlockedAchievementIds, dispatch]);
|
|
2334
|
+
}, [config, metrics, unlockedAchievementIds, previouslyAwardedAchievementIds, dispatch]);
|
|
2307
2335
|
React.useEffect(() => {
|
|
2308
2336
|
checkAchievements();
|
|
2309
2337
|
}, [metrics, checkAchievements]);
|
|
@@ -2318,7 +2346,8 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2318
2346
|
.filter((c) => achievedIds.includes(c.achievementDetails.achievementId))
|
|
2319
2347
|
.map((c) => c.achievementDetails));
|
|
2320
2348
|
}, [config]);
|
|
2321
|
-
|
|
2349
|
+
getAchievements(unlockedAchievementIds);
|
|
2350
|
+
const previouslyAwardedAchievementsDetails = getAchievements(previouslyAwardedAchievementIds);
|
|
2322
2351
|
return (React.createElement(AchievementContext.Provider, { value: { updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage } },
|
|
2323
2352
|
children,
|
|
2324
2353
|
React.createElement(AchievementModal$1, { isOpen: !!currentAchievement, achievement: currentAchievement, onClose: () => {
|
|
@@ -2327,8 +2356,8 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2327
2356
|
dispatch(clearNotifications());
|
|
2328
2357
|
}
|
|
2329
2358
|
}, styles: mergedStyles.achievementModal, icons: mergedIcons }),
|
|
2330
|
-
React.createElement(BadgesModal$1, { isOpen: showBadges, achievements:
|
|
2331
|
-
React.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements:
|
|
2359
|
+
React.createElement(BadgesModal$1, { isOpen: showBadges, achievements: previouslyAwardedAchievementsDetails, onClose: () => setShowBadges(false), styles: mergedStyles.badgesModal, icons: mergedIcons }),
|
|
2360
|
+
React.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements: previouslyAwardedAchievementsDetails }),
|
|
2332
2361
|
React.createElement(ConfettiWrapper, { show: showConfetti || notifications.length > 0 })));
|
|
2333
2362
|
};
|
|
2334
2363
|
function isObject(item) {
|
|
@@ -2354,8 +2383,18 @@ function mergeDeep(target, source) {
|
|
|
2354
2383
|
return output;
|
|
2355
2384
|
}
|
|
2356
2385
|
|
|
2386
|
+
const useAchievementState = () => {
|
|
2387
|
+
const metrics = useSelector((state) => state.achievements.metrics);
|
|
2388
|
+
const previouslyAwardedAchievements = useSelector((state) => state.achievements.previouslyAwardedAchievements);
|
|
2389
|
+
return {
|
|
2390
|
+
metrics,
|
|
2391
|
+
previouslyAwardedAchievements,
|
|
2392
|
+
};
|
|
2393
|
+
};
|
|
2394
|
+
|
|
2357
2395
|
exports.AchievementProvider = AchievementProvider;
|
|
2358
2396
|
exports.ConfettiWrapper = ConfettiWrapper;
|
|
2359
2397
|
exports.achievementReducer = achievementSlice$1;
|
|
2360
2398
|
exports.notificationReducer = notificationSlice$1;
|
|
2361
2399
|
exports.useAchievement = useAchievementContext;
|
|
2400
|
+
exports.useAchievementState = useAchievementState;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ 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
|
-
|
|
6
|
+
import { useAchievementState } from './hooks/useAchievementState';
|
|
7
|
+
export { AchievementProvider, useAchievement, AchievementMetrics, AchievementConfiguration, AchievementDetails, AchievementUnlockCondition, ConfettiWrapper, achievementReducer, notificationReducer, useAchievementState, };
|
package/dist/index.esm.js
CHANGED
|
@@ -1812,10 +1812,12 @@ typeof queueMicrotask === "function" ? queueMicrotask.bind(typeof window !== "un
|
|
|
1812
1812
|
// src/index.ts
|
|
1813
1813
|
F();
|
|
1814
1814
|
|
|
1815
|
+
// src/redux/achievementSlice.ts
|
|
1815
1816
|
const initialState$1 = {
|
|
1816
1817
|
config: {},
|
|
1817
1818
|
metrics: {},
|
|
1818
1819
|
unlockedAchievements: [],
|
|
1820
|
+
previouslyAwardedAchievements: [], // Initialize as empty
|
|
1819
1821
|
storageKey: null,
|
|
1820
1822
|
};
|
|
1821
1823
|
const achievementSlice = createSlice({
|
|
@@ -1823,51 +1825,67 @@ const achievementSlice = createSlice({
|
|
|
1823
1825
|
initialState: initialState$1,
|
|
1824
1826
|
reducers: {
|
|
1825
1827
|
initialize: (state, action) => {
|
|
1826
|
-
var _a, _b;
|
|
1828
|
+
var _a, _b, _c, _d;
|
|
1827
1829
|
state.config = action.payload.config;
|
|
1828
1830
|
state.storageKey = action.payload.storageKey;
|
|
1829
1831
|
const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
|
|
1832
|
+
const initialMetrics = action.payload.initialState ? Object.keys(action.payload.initialState)
|
|
1833
|
+
.filter(key => key !== 'previouslyAwardedAchievements')
|
|
1834
|
+
.reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
|
|
1835
|
+
const initialAwarded = ((_a = action.payload.initialState) === null || _a === void 0 ? void 0 : _a.previouslyAwardedAchievements) || [];
|
|
1830
1836
|
if (storedState) {
|
|
1831
1837
|
try {
|
|
1832
1838
|
const parsedState = JSON.parse(storedState);
|
|
1833
|
-
state.metrics = ((
|
|
1834
|
-
state.unlockedAchievements = ((
|
|
1839
|
+
state.metrics = ((_b = parsedState.achievements) === null || _b === void 0 ? void 0 : _b.metrics) || initialMetrics;
|
|
1840
|
+
state.unlockedAchievements = ((_c = parsedState.achievements) === null || _c === void 0 ? void 0 : _c.unlockedAchievements) || [];
|
|
1841
|
+
state.previouslyAwardedAchievements = ((_d = parsedState.achievements) === null || _d === void 0 ? void 0 : _d.previouslyAwardedAchievements) || initialAwarded; // Prioritize stored, fallback to initial
|
|
1835
1842
|
}
|
|
1836
1843
|
catch (error) {
|
|
1837
1844
|
console.error('Error parsing stored achievement state:', error);
|
|
1838
|
-
state.metrics =
|
|
1845
|
+
state.metrics = initialMetrics;
|
|
1839
1846
|
state.unlockedAchievements = [];
|
|
1847
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
1840
1848
|
}
|
|
1841
1849
|
}
|
|
1842
1850
|
else {
|
|
1843
|
-
state.metrics =
|
|
1851
|
+
state.metrics = initialMetrics;
|
|
1844
1852
|
state.unlockedAchievements = [];
|
|
1853
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
1845
1854
|
}
|
|
1846
1855
|
},
|
|
1847
1856
|
setMetrics: (state, action) => {
|
|
1848
1857
|
state.metrics = action.payload;
|
|
1849
1858
|
if (state.storageKey) {
|
|
1850
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
1859
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1851
1860
|
}
|
|
1852
1861
|
},
|
|
1853
1862
|
unlockAchievement: (state, action) => {
|
|
1854
1863
|
if (!state.unlockedAchievements.includes(action.payload)) {
|
|
1855
1864
|
state.unlockedAchievements.push(action.payload);
|
|
1856
1865
|
if (state.storageKey) {
|
|
1857
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
1866
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
},
|
|
1870
|
+
markAchievementAsAwarded: (state, action) => {
|
|
1871
|
+
if (!state.previouslyAwardedAchievements.includes(action.payload)) {
|
|
1872
|
+
state.previouslyAwardedAchievements.push(action.payload);
|
|
1873
|
+
if (state.storageKey) {
|
|
1874
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
1858
1875
|
}
|
|
1859
1876
|
}
|
|
1860
1877
|
},
|
|
1861
1878
|
resetAchievements: (state) => {
|
|
1862
1879
|
state.metrics = {};
|
|
1863
1880
|
state.unlockedAchievements = [];
|
|
1881
|
+
state.previouslyAwardedAchievements = [];
|
|
1864
1882
|
if (state.storageKey) {
|
|
1865
1883
|
localStorage.removeItem(state.storageKey);
|
|
1866
1884
|
}
|
|
1867
1885
|
},
|
|
1868
1886
|
},
|
|
1869
1887
|
});
|
|
1870
|
-
const { initialize, setMetrics, unlockAchievement, resetAchievements } = achievementSlice.actions;
|
|
1888
|
+
const { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } = achievementSlice.actions;
|
|
1871
1889
|
var achievementSlice$1 = achievementSlice.reducer;
|
|
1872
1890
|
|
|
1873
1891
|
const initialState = {
|
|
@@ -2242,6 +2260,7 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2242
2260
|
const dispatch = useDispatch();
|
|
2243
2261
|
const metrics = useSelector((state) => state.achievements.metrics);
|
|
2244
2262
|
const unlockedAchievementIds = useSelector((state) => state.achievements.unlockedAchievements);
|
|
2263
|
+
const previouslyAwardedAchievementIds = useSelector((state) => state.achievements.previouslyAwardedAchievements);
|
|
2245
2264
|
const notifications = useSelector((state) => state.notifications.notifications);
|
|
2246
2265
|
const mergedStyles = React__default.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
|
|
2247
2266
|
const [currentAchievement, setCurrentAchievement] = React__default.useState(null);
|
|
@@ -2259,31 +2278,40 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2259
2278
|
dispatch(initialize({ config, initialState, storageKey }));
|
|
2260
2279
|
}, [dispatch, config, initialState, storageKey]);
|
|
2261
2280
|
const checkAchievements = useCallback(() => {
|
|
2262
|
-
const
|
|
2281
|
+
const newAchievementsToAward = [];
|
|
2263
2282
|
if (!unlockedAchievementIds) {
|
|
2264
2283
|
console.error('unlockedAchievements is undefined!');
|
|
2265
2284
|
return;
|
|
2266
2285
|
}
|
|
2267
|
-
Object.entries(config)
|
|
2286
|
+
Object.entries(config)
|
|
2287
|
+
.forEach(([metricName, conditions]) => {
|
|
2268
2288
|
const metricValues = metrics[metricName];
|
|
2269
2289
|
if (!metricValues) {
|
|
2270
2290
|
return;
|
|
2271
2291
|
}
|
|
2272
|
-
conditions
|
|
2292
|
+
conditions
|
|
2293
|
+
.filter(condition => !previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId))
|
|
2294
|
+
.forEach((condition) => {
|
|
2273
2295
|
if (metricValues.some((value) => condition.isConditionMet(value)) &&
|
|
2274
2296
|
!unlockedAchievementIds.includes(condition.achievementDetails.achievementId)) {
|
|
2275
|
-
|
|
2297
|
+
dispatch(unlockAchievement(condition.achievementDetails.achievementId));
|
|
2298
|
+
newAchievementsToAward.push(condition.achievementDetails);
|
|
2299
|
+
}
|
|
2300
|
+
else if (metricValues.some((value) => condition.isConditionMet(value)) &&
|
|
2301
|
+
unlockedAchievementIds.includes(condition.achievementDetails.achievementId) &&
|
|
2302
|
+
!previouslyAwardedAchievementIds.includes(condition.achievementDetails.achievementId)) {
|
|
2303
|
+
newAchievementsToAward.push(condition.achievementDetails);
|
|
2276
2304
|
}
|
|
2277
2305
|
});
|
|
2278
2306
|
});
|
|
2279
|
-
if (
|
|
2280
|
-
|
|
2281
|
-
dispatch(unlockAchievement(achievement.achievementId));
|
|
2307
|
+
if (newAchievementsToAward.length > 0) {
|
|
2308
|
+
newAchievementsToAward.forEach((achievement) => {
|
|
2282
2309
|
dispatch(addNotification(achievement));
|
|
2310
|
+
dispatch(markAchievementAsAwarded(achievement.achievementId));
|
|
2283
2311
|
});
|
|
2284
2312
|
setShowConfetti(true);
|
|
2285
2313
|
}
|
|
2286
|
-
}, [config, metrics, unlockedAchievementIds, dispatch]);
|
|
2314
|
+
}, [config, metrics, unlockedAchievementIds, previouslyAwardedAchievementIds, dispatch]);
|
|
2287
2315
|
useEffect(() => {
|
|
2288
2316
|
checkAchievements();
|
|
2289
2317
|
}, [metrics, checkAchievements]);
|
|
@@ -2298,7 +2326,8 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2298
2326
|
.filter((c) => achievedIds.includes(c.achievementDetails.achievementId))
|
|
2299
2327
|
.map((c) => c.achievementDetails));
|
|
2300
2328
|
}, [config]);
|
|
2301
|
-
|
|
2329
|
+
getAchievements(unlockedAchievementIds);
|
|
2330
|
+
const previouslyAwardedAchievementsDetails = getAchievements(previouslyAwardedAchievementIds);
|
|
2302
2331
|
return (React__default.createElement(AchievementContext.Provider, { value: { updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage } },
|
|
2303
2332
|
children,
|
|
2304
2333
|
React__default.createElement(AchievementModal$1, { isOpen: !!currentAchievement, achievement: currentAchievement, onClose: () => {
|
|
@@ -2307,8 +2336,8 @@ const AchievementProvider = ({ children, config, initialState = {}, storageKey =
|
|
|
2307
2336
|
dispatch(clearNotifications());
|
|
2308
2337
|
}
|
|
2309
2338
|
}, styles: mergedStyles.achievementModal, icons: mergedIcons }),
|
|
2310
|
-
React__default.createElement(BadgesModal$1, { isOpen: showBadges, achievements:
|
|
2311
|
-
React__default.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements:
|
|
2339
|
+
React__default.createElement(BadgesModal$1, { isOpen: showBadges, achievements: previouslyAwardedAchievementsDetails, onClose: () => setShowBadges(false), styles: mergedStyles.badgesModal, icons: mergedIcons }),
|
|
2340
|
+
React__default.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements: previouslyAwardedAchievementsDetails }),
|
|
2312
2341
|
React__default.createElement(ConfettiWrapper, { show: showConfetti || notifications.length > 0 })));
|
|
2313
2342
|
};
|
|
2314
2343
|
function isObject(item) {
|
|
@@ -2334,4 +2363,13 @@ function mergeDeep(target, source) {
|
|
|
2334
2363
|
return output;
|
|
2335
2364
|
}
|
|
2336
2365
|
|
|
2337
|
-
|
|
2366
|
+
const useAchievementState = () => {
|
|
2367
|
+
const metrics = useSelector((state) => state.achievements.metrics);
|
|
2368
|
+
const previouslyAwardedAchievements = useSelector((state) => state.achievements.previouslyAwardedAchievements);
|
|
2369
|
+
return {
|
|
2370
|
+
metrics,
|
|
2371
|
+
previouslyAwardedAchievements,
|
|
2372
|
+
};
|
|
2373
|
+
};
|
|
2374
|
+
|
|
2375
|
+
export { AchievementProvider, ConfettiWrapper, achievementSlice$1 as achievementReducer, notificationSlice$1 as notificationReducer, useAchievementContext as useAchievement, useAchievementState };
|
|
@@ -4,22 +4,28 @@ export interface AchievementState {
|
|
|
4
4
|
config: AchievementConfiguration;
|
|
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
12
|
config: AchievementConfiguration;
|
|
12
|
-
initialState?: InitialAchievementMetrics
|
|
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
24
|
config: AchievementConfiguration;
|
|
21
|
-
initialState?: InitialAchievementMetrics
|
|
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,10 +15,12 @@ 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> {
|
package/package.json
CHANGED
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useCallback } 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,
|
|
@@ -45,6 +45,7 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
|
45
45
|
const dispatch: AppDispatch = useDispatch();
|
|
46
46
|
const metrics = useSelector((state: RootState) => state.achievements.metrics);
|
|
47
47
|
const unlockedAchievementIds = useSelector((state: RootState) => state.achievements.unlockedAchievements);
|
|
48
|
+
const previouslyAwardedAchievementIds = useSelector((state: RootState) => state.achievements.previouslyAwardedAchievements);
|
|
48
49
|
const notifications = useSelector((state: RootState) => state.notifications.notifications);
|
|
49
50
|
const mergedStyles = React.useMemo(() => mergeDeep(defaultStyles, styles), [styles]);
|
|
50
51
|
|
|
@@ -71,38 +72,48 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
|
71
72
|
}, [dispatch, config, initialState, storageKey]);
|
|
72
73
|
|
|
73
74
|
const checkAchievements = useCallback(() => {
|
|
74
|
-
const
|
|
75
|
+
const newAchievementsToAward: AchievementDetails[] = [];
|
|
75
76
|
|
|
76
77
|
if (!unlockedAchievementIds) {
|
|
77
78
|
console.error('unlockedAchievements is undefined!');
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
Object.entries(config)
|
|
82
|
-
|
|
82
|
+
Object.entries(config)
|
|
83
|
+
.forEach(([metricName, conditions]) => {
|
|
84
|
+
const metricValues = metrics[metricName];
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
|
86
|
+
if (!metricValues) {
|
|
87
|
+
return;
|
|
94
88
|
}
|
|
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
|
+
});
|
|
95
107
|
});
|
|
96
|
-
});
|
|
97
108
|
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
dispatch(unlockAchievement(achievement.achievementId));
|
|
109
|
+
if (newAchievementsToAward.length > 0) {
|
|
110
|
+
newAchievementsToAward.forEach((achievement) => {
|
|
101
111
|
dispatch(addNotification(achievement));
|
|
112
|
+
dispatch(markAchievementAsAwarded(achievement.achievementId));
|
|
102
113
|
});
|
|
103
114
|
setShowConfetti(true);
|
|
104
115
|
}
|
|
105
|
-
}, [config, metrics, unlockedAchievementIds, dispatch]);
|
|
116
|
+
}, [config, metrics, unlockedAchievementIds, previouslyAwardedAchievementIds, dispatch]);
|
|
106
117
|
|
|
107
118
|
useEffect(() => {
|
|
108
119
|
checkAchievements();
|
|
@@ -128,6 +139,7 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
|
128
139
|
);
|
|
129
140
|
|
|
130
141
|
const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
|
|
142
|
+
const previouslyAwardedAchievementsDetails = getAchievements(previouslyAwardedAchievementIds);
|
|
131
143
|
|
|
132
144
|
return (
|
|
133
145
|
<AchievementContext.Provider value={{ updateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage }}>
|
|
@@ -146,7 +158,7 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
|
146
158
|
/>
|
|
147
159
|
<BadgesModal
|
|
148
160
|
isOpen={showBadges}
|
|
149
|
-
achievements={
|
|
161
|
+
achievements={previouslyAwardedAchievementsDetails}
|
|
150
162
|
onClose={() => setShowBadges(false)}
|
|
151
163
|
styles={mergedStyles.badgesModal}
|
|
152
164
|
icons={mergedIcons}
|
|
@@ -155,7 +167,7 @@ export const AchievementProvider: React.FC<AchievementProviderProps> = ({
|
|
|
155
167
|
onClick={showBadgesModal}
|
|
156
168
|
position={badgesButtonPosition}
|
|
157
169
|
styles={mergedStyles.badgesButton}
|
|
158
|
-
unlockedAchievements={
|
|
170
|
+
unlockedAchievements={previouslyAwardedAchievementsDetails}
|
|
159
171
|
/>
|
|
160
172
|
<ConfettiWrapper show={showConfetti || notifications.length > 0} />
|
|
161
173
|
</AchievementContext.Provider>
|
|
@@ -182,4 +194,4 @@ export function mergeDeep(target: any, source: any) {
|
|
|
182
194
|
});
|
|
183
195
|
}
|
|
184
196
|
return output;
|
|
185
|
-
}
|
|
197
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// src/redux/achievementSlice.ts
|
|
1
2
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
2
3
|
import {
|
|
3
4
|
AchievementConfiguration,
|
|
@@ -9,6 +10,7 @@ export interface AchievementState {
|
|
|
9
10
|
config: AchievementConfiguration;
|
|
10
11
|
metrics: AchievementMetrics;
|
|
11
12
|
unlockedAchievements: string[];
|
|
13
|
+
previouslyAwardedAchievements: string[];
|
|
12
14
|
storageKey: string | null;
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -16,6 +18,7 @@ const initialState: AchievementState = {
|
|
|
16
18
|
config: {},
|
|
17
19
|
metrics: {},
|
|
18
20
|
unlockedAchievements: [],
|
|
21
|
+
previouslyAwardedAchievements: [], // Initialize as empty
|
|
19
22
|
storageKey: null,
|
|
20
23
|
};
|
|
21
24
|
|
|
@@ -23,42 +26,61 @@ export const achievementSlice = createSlice({
|
|
|
23
26
|
name: 'achievements',
|
|
24
27
|
initialState,
|
|
25
28
|
reducers: {
|
|
26
|
-
initialize: (state, action: PayloadAction<{ config: AchievementConfiguration; initialState?: InitialAchievementMetrics; storageKey: string }>) => {
|
|
29
|
+
initialize: (state, action: PayloadAction<{ config: AchievementConfiguration; initialState?: InitialAchievementMetrics & { previouslyAwardedAchievements?: string[] }; storageKey: string }>) => {
|
|
27
30
|
state.config = action.payload.config;
|
|
28
31
|
state.storageKey = action.payload.storageKey;
|
|
29
32
|
const storedState = action.payload.storageKey ? localStorage.getItem(action.payload.storageKey) : null;
|
|
33
|
+
|
|
34
|
+
const initialMetrics = action.payload.initialState ? Object.keys(action.payload.initialState)
|
|
35
|
+
.filter(key => key !== 'previouslyAwardedAchievements')
|
|
36
|
+
.reduce((acc, key) => ({ ...acc, [key]: Array.isArray(action.payload.initialState![key]) ? action.payload.initialState![key] : [action.payload.initialState![key]] }), {}) : {};
|
|
37
|
+
|
|
38
|
+
const initialAwarded = action.payload.initialState?.previouslyAwardedAchievements || [];
|
|
39
|
+
|
|
30
40
|
if (storedState) {
|
|
31
41
|
try {
|
|
32
42
|
const parsedState = JSON.parse(storedState);
|
|
33
|
-
state.metrics = parsedState.achievements?.metrics ||
|
|
43
|
+
state.metrics = parsedState.achievements?.metrics || initialMetrics;
|
|
34
44
|
state.unlockedAchievements = parsedState.achievements?.unlockedAchievements || [];
|
|
45
|
+
state.previouslyAwardedAchievements = parsedState.achievements?.previouslyAwardedAchievements || initialAwarded; // Prioritize stored, fallback to initial
|
|
35
46
|
} catch (error) {
|
|
36
47
|
console.error('Error parsing stored achievement state:', error);
|
|
37
|
-
state.metrics =
|
|
48
|
+
state.metrics = initialMetrics;
|
|
38
49
|
state.unlockedAchievements = [];
|
|
50
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
39
51
|
}
|
|
40
52
|
} else {
|
|
41
|
-
state.metrics =
|
|
53
|
+
state.metrics = initialMetrics;
|
|
42
54
|
state.unlockedAchievements = [];
|
|
55
|
+
state.previouslyAwardedAchievements = initialAwarded;
|
|
43
56
|
}
|
|
44
57
|
},
|
|
45
58
|
setMetrics: (state, action: PayloadAction<AchievementMetrics>) => {
|
|
46
59
|
state.metrics = action.payload;
|
|
47
60
|
if (state.storageKey) {
|
|
48
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
61
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
49
62
|
}
|
|
50
63
|
},
|
|
51
64
|
unlockAchievement: (state, action: PayloadAction<string>) => {
|
|
52
65
|
if (!state.unlockedAchievements.includes(action.payload)) {
|
|
53
66
|
state.unlockedAchievements.push(action.payload);
|
|
54
67
|
if (state.storageKey) {
|
|
55
|
-
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements } }));
|
|
68
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
markAchievementAsAwarded: (state, action: PayloadAction<string>) => {
|
|
73
|
+
if (!state.previouslyAwardedAchievements.includes(action.payload)) {
|
|
74
|
+
state.previouslyAwardedAchievements.push(action.payload);
|
|
75
|
+
if (state.storageKey) {
|
|
76
|
+
localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } }));
|
|
56
77
|
}
|
|
57
78
|
}
|
|
58
79
|
},
|
|
59
80
|
resetAchievements: (state) => {
|
|
60
81
|
state.metrics = {};
|
|
61
82
|
state.unlockedAchievements = [];
|
|
83
|
+
state.previouslyAwardedAchievements = [];
|
|
62
84
|
if (state.storageKey) {
|
|
63
85
|
localStorage.removeItem(state.storageKey);
|
|
64
86
|
}
|
|
@@ -66,6 +88,6 @@ export const achievementSlice = createSlice({
|
|
|
66
88
|
},
|
|
67
89
|
});
|
|
68
90
|
|
|
69
|
-
export const { initialize, setMetrics, unlockAchievement, resetAchievements } = achievementSlice.actions;
|
|
91
|
+
export const { initialize, setMetrics, unlockAchievement, resetAchievements, markAchievementAsAwarded } = achievementSlice.actions;
|
|
70
92
|
|
|
71
93
|
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,10 +21,10 @@ 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
|
|