react-achievements 2.0.3 → 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 CHANGED
@@ -13,13 +13,13 @@ https://stackblitz.com/edit/vitejs-vite-sccdux
13
13
  Install `react-achievements` using npm or yarn:
14
14
 
15
15
  ```bash
16
- npm install react react-dom react-redux @reduxjs/toolkit react-achievements
16
+ npm install react react-dom @reduxjs/toolkit react-achievements
17
17
  ```
18
18
 
19
19
  or
20
20
 
21
21
  ```bash
22
- yarn add react react-dom react-redux @reduxjs/toolkit react-achievements
22
+ yarn add react react-dom @reduxjs/toolkit react-achievements
23
23
  ```
24
24
 
25
25
  <h2 align="center">🎮 Usage</h2>
@@ -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,154 @@ 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
+
514
+ <h2 align="center">🏆Available Icons🏆</h2>
515
+
516
+ ```
517
+ // General Progress & Milestones
518
+ levelUp: '🏆',
519
+ questComplete: '📜',
520
+ monsterDefeated: '⚔️',
521
+ itemCollected: '📦',
522
+ challengeCompleted: '🏁',
523
+ milestoneReached: '🏅',
524
+ firstStep: '👣',
525
+ newBeginnings: '🌱',
526
+ breakthrough: '💡',
527
+ growth: '📈',
528
+
529
+ // Social & Engagement
530
+ shared: '🔗',
531
+ liked: '❤️',
532
+ commented: '💬',
533
+ followed: '👥',
534
+ invited: '🤝',
535
+ communityMember: '🏘️',
536
+ supporter: '🌟',
537
+ connected: '🌐',
538
+ participant: '🙋',
539
+ influencer: '📣',
540
+
541
+ // Time & Activity
542
+ activeDay: '☀️',
543
+ activeWeek: '📅',
544
+ activeMonth: '🗓️',
545
+ earlyBird: '⏰',
546
+ nightOwl: '🌙',
547
+ streak: '🔥',
548
+ dedicated: '⏳',
549
+ punctual: '⏱️',
550
+ consistent: '🔄',
551
+ marathon: '🏃',
552
+
553
+ // Creativity & Skill
554
+ artist: '🎨',
555
+ writer: '✍️',
556
+ innovator: '🔬',
557
+ creator: '🛠️',
558
+ expert: '🎓',
559
+ master: '👑',
560
+ pioneer: '🚀',
561
+ performer: '🎭',
562
+ thinker: '🧠',
563
+ explorer: '🗺️',
564
+
565
+ // Achievement Types
566
+ bronze: '🥉',
567
+ silver: '🥈',
568
+ gold: '🥇',
569
+ diamond: '💎',
570
+ legendary: '✨',
571
+ epic: '💥',
572
+ rare: '🔮',
573
+ common: '🔘',
574
+ special: '🎁',
575
+ hidden: '❓',
576
+
577
+ // Numbers & Counters
578
+ one: '1️⃣',
579
+ ten: '🔟',
580
+ hundred: '💯',
581
+ thousand: '🔢',
582
+
583
+ // Actions & Interactions
584
+ clicked: '🖱️',
585
+ used: '🔑',
586
+ found: '🔍',
587
+ built: '🧱',
588
+ solved: '🧩',
589
+ discovered: '🔭',
590
+ unlocked: '🔓',
591
+ upgraded: '⬆️',
592
+ repaired: '🔧',
593
+ defended: '🛡️',
594
+
595
+ // Placeholders
596
+ default: '⭐', // A fallback icon
597
+ loading: '⏳',
598
+ error: '⚠️',
599
+ success: '✅',
600
+ failure: '❌',
601
+
602
+ // Miscellaneous
603
+ trophy: '🏆',
604
+ star: '⭐',
605
+ flag: '🚩',
606
+ puzzle: '🧩',
607
+ gem: '💎',
608
+ crown: '👑',
609
+ medal: '🏅',
610
+ ribbon: '🎗️',
611
+ badge: '🎖️',
612
+ shield: '🛡️',
613
+ ```
614
+
415
615
  <h2 align="center">📄 License</h2>
416
616
  MIT
417
617
 
@@ -0,0 +1,4 @@
1
+ export declare const useAchievementState: () => {
2
+ metrics: import("..").AchievementMetrics;
3
+ previouslyAwardedAchievements: string[];
4
+ };
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 = ((_a = parsedState.achievements) === null || _a === void 0 ? void 0 : _a.metrics) || {};
1854
- state.unlockedAchievements = ((_b = parsedState.achievements) === null || _b === void 0 ? void 0 : _b.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 = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
1865
+ state.metrics = initialMetrics;
1859
1866
  state.unlockedAchievements = [];
1867
+ state.previouslyAwardedAchievements = initialAwarded;
1860
1868
  }
1861
1869
  }
1862
1870
  else {
1863
- state.metrics = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
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 newAchievements = [];
2301
+ const newAchievementsToAward = [];
2283
2302
  if (!unlockedAchievementIds) {
2284
2303
  console.error('unlockedAchievements is undefined!');
2285
2304
  return;
2286
2305
  }
2287
- Object.entries(config).forEach(([metricName, conditions]) => {
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.forEach((condition) => {
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
- newAchievements.push(condition.achievementDetails);
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 (newAchievements.length > 0) {
2300
- newAchievements.forEach((achievement) => {
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
- const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
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: unlockedAchievementsDetails, onClose: () => setShowBadges(false), styles: mergedStyles.badgesModal, icons: mergedIcons }),
2331
- React.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements: unlockedAchievementsDetails }),
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
- export { AchievementProvider, useAchievement, AchievementMetrics, AchievementConfiguration, AchievementDetails, AchievementUnlockCondition, ConfettiWrapper, achievementReducer, notificationReducer, };
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 = ((_a = parsedState.achievements) === null || _a === void 0 ? void 0 : _a.metrics) || {};
1834
- state.unlockedAchievements = ((_b = parsedState.achievements) === null || _b === void 0 ? void 0 : _b.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 = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
1845
+ state.metrics = initialMetrics;
1839
1846
  state.unlockedAchievements = [];
1847
+ state.previouslyAwardedAchievements = initialAwarded;
1840
1848
  }
1841
1849
  }
1842
1850
  else {
1843
- state.metrics = action.payload.initialState ? Object.keys(action.payload.initialState).reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(action.payload.initialState[key]) ? action.payload.initialState[key] : [action.payload.initialState[key]] })), {}) : {};
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 newAchievements = [];
2281
+ const newAchievementsToAward = [];
2263
2282
  if (!unlockedAchievementIds) {
2264
2283
  console.error('unlockedAchievements is undefined!');
2265
2284
  return;
2266
2285
  }
2267
- Object.entries(config).forEach(([metricName, conditions]) => {
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.forEach((condition) => {
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
- newAchievements.push(condition.achievementDetails);
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 (newAchievements.length > 0) {
2280
- newAchievements.forEach((achievement) => {
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
- const unlockedAchievementsDetails = getAchievements(unlockedAchievementIds);
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: unlockedAchievementsDetails, onClose: () => setShowBadges(false), styles: mergedStyles.badgesModal, icons: mergedIcons }),
2311
- React__default.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: mergedStyles.badgesButton, unlockedAchievements: unlockedAchievementsDetails }),
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
- export { AchievementProvider, ConfettiWrapper, achievementSlice$1 as achievementReducer, notificationSlice$1 as notificationReducer, useAchievementContext as useAchievement };
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<import('./defaultStyles').Styles>;
23
+ styles?: Partial<Styles>;
21
24
  icons?: Record<string, string>;
22
25
  }
23
26
  export interface AchievementUnlockCondition<T extends AchievementMetricValue> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-achievements",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "This package allows users to transpose a React achievements engine over their React apps",
5
5
  "keywords": [
6
6
  "react",
@@ -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 newAchievements: AchievementDetails[] = [];
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).forEach(([metricName, conditions]) => {
82
- const metricValues = metrics[metricName];
82
+ Object.entries(config)
83
+ .forEach(([metricName, conditions]) => {
84
+ const metricValues = metrics[metricName];
83
85
 
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);
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 (newAchievements.length > 0) {
99
- newAchievements.forEach((achievement) => {
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={unlockedAchievementsDetails}
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={unlockedAchievementsDetails}
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 = 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]] }), {}) : {};
48
+ state.metrics = initialMetrics;
38
49
  state.unlockedAchievements = [];
50
+ state.previouslyAwardedAchievements = initialAwarded;
39
51
  }
40
52
  } 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]] }), {}) : {};
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<import('./defaultStyles').Styles>;
27
+ styles?: Partial<Styles>;
26
28
  icons?: Record<string, string>;
27
29
  }
28
30