react-achievements 3.0.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -99,6 +99,29 @@ class LocalStorage {
99
99
  }
100
100
  }
101
101
 
102
+ class MemoryStorage {
103
+ constructor() {
104
+ this.metrics = {};
105
+ this.unlockedAchievements = [];
106
+ }
107
+ getMetrics() {
108
+ return this.metrics;
109
+ }
110
+ setMetrics(metrics) {
111
+ this.metrics = metrics;
112
+ }
113
+ getUnlockedAchievements() {
114
+ return this.unlockedAchievements;
115
+ }
116
+ setUnlockedAchievements(achievements) {
117
+ this.unlockedAchievements = achievements;
118
+ }
119
+ clear() {
120
+ this.metrics = {};
121
+ this.unlockedAchievements = [];
122
+ }
123
+ }
124
+
102
125
  const getPositionStyles = (position) => {
103
126
  const base = {
104
127
  position: 'fixed',
@@ -129,94 +152,14 @@ const BadgesButton = ({ onClick, position, styles = {}, unlockedAchievements })
129
152
  };
130
153
 
131
154
  const defaultAchievementIcons = {
132
- // General Progress & Milestones
133
- levelUp: '🏆',
134
- questComplete: '📜',
135
- monsterDefeated: '⚔️',
136
- itemCollected: '📦',
137
- challengeCompleted: '🏁',
138
- milestoneReached: '🏅',
139
- firstStep: '👣',
140
- newBeginnings: '🌱',
141
- breakthrough: '💡',
142
- growth: '📈',
143
- // Social & Engagement
144
- shared: '🔗',
145
- liked: '❤️',
146
- commented: '💬',
147
- followed: '👥',
148
- invited: '🤝',
149
- communityMember: '🏘️',
150
- supporter: '🌟',
151
- connected: '🌐',
152
- participant: '🙋',
153
- influencer: '📣',
154
- // Time & Activity
155
- activeDay: '☀️',
156
- activeWeek: '📅',
157
- activeMonth: '🗓️',
158
- earlyBird: '⏰',
159
- nightOwl: '🌙',
160
- streak: '🔥',
161
- dedicated: '⏳',
162
- punctual: '⏱️',
163
- consistent: '🔄',
164
- marathon: '🏃',
165
- // Creativity & Skill
166
- artist: '🎨',
167
- writer: '✍️',
168
- innovator: '🔬',
169
- creator: '🛠️',
170
- expert: '🎓',
171
- master: '👑',
172
- pioneer: '🚀',
173
- performer: '🎭',
174
- thinker: '🧠',
175
- explorer: '🗺️',
176
- // Achievement Types
177
- bronze: '🥉',
178
- silver: '🥈',
179
- gold: '🥇',
180
- diamond: '💎',
181
- legendary: '✨',
182
- epic: '💥',
183
- rare: '🔮',
184
- common: '🔘',
185
- special: '🎁',
186
- hidden: '❓',
187
- // Numbers & Counters
188
- one: '1️⃣',
189
- ten: '🔟',
190
- hundred: '💯',
191
- thousand: '🔢',
192
- // Actions & Interactions
193
- clicked: '🖱️',
194
- used: '🔑',
195
- found: '🔍',
196
- built: '🧱',
197
- solved: '🧩',
198
- discovered: '🔭',
199
- unlocked: '🔓',
200
- upgraded: '⬆️',
201
- repaired: '🔧',
202
- defended: '🛡️',
203
- // Placeholders
204
- default: '⭐', // A fallback icon
205
- loading: '⏳',
206
- error: '⚠️',
207
- success: '✅',
208
- failure: '❌',
209
- // Miscellaneous
155
+ // Essential fallback icons for system use
156
+ default: '', // Fallback when no icon is provided
157
+ loading: '', // For loading states
158
+ error: '⚠️', // For error states
159
+ success: '', // For success states
160
+ // A few common icons for backward compatibility
210
161
  trophy: '🏆',
211
162
  star: '⭐',
212
- flag: '🚩',
213
- puzzle: '🧩',
214
- gem: '💎',
215
- crown: '👑',
216
- medal: '🏅',
217
- ribbon: '🎗️',
218
- badge: '🎖️',
219
- shield: '🛡️',
220
163
  };
221
164
 
222
165
  const BadgesModal = ({ isOpen, onClose, achievements, styles = {}, icons = {} }) => {
@@ -316,31 +259,100 @@ const ConfettiWrapper = ({ show }) => {
316
259
  } }));
317
260
  };
318
261
 
319
- class MemoryStorage {
320
- constructor() {
321
- this.metrics = {};
322
- this.unlockedAchievements = [];
323
- }
324
- getMetrics() {
325
- return this.metrics;
326
- }
327
- setMetrics(metrics) {
328
- this.metrics = metrics;
329
- }
330
- getUnlockedAchievements() {
331
- return this.unlockedAchievements;
332
- }
333
- setUnlockedAchievements(achievements) {
334
- this.unlockedAchievements = achievements;
335
- }
336
- clear() {
337
- this.metrics = {};
338
- this.unlockedAchievements = [];
262
+ // Type guard to check if config is simple format
263
+ function isSimpleConfig(config) {
264
+ if (!config || typeof config !== 'object')
265
+ return false;
266
+ const firstKey = Object.keys(config)[0];
267
+ if (!firstKey)
268
+ return true; // Empty config is considered simple
269
+ const firstValue = config[firstKey];
270
+ // Check if it's the current complex format (array of AchievementCondition)
271
+ if (Array.isArray(firstValue))
272
+ return false;
273
+ // Check if it's the simple format (object with string keys)
274
+ return typeof firstValue === 'object' && !Array.isArray(firstValue);
275
+ }
276
+ // Generate a unique ID for achievements
277
+ function generateId() {
278
+ return Math.random().toString(36).substr(2, 9);
279
+ }
280
+ // Check if achievement details has a custom condition
281
+ function hasCustomCondition(details) {
282
+ return 'condition' in details && typeof details.condition === 'function';
283
+ }
284
+ // Convert simple config to complex config format
285
+ function normalizeAchievements(config) {
286
+ if (!isSimpleConfig(config)) {
287
+ // Already in complex format, return as-is
288
+ return config;
339
289
  }
290
+ const normalized = {};
291
+ Object.entries(config).forEach(([metric, achievements]) => {
292
+ normalized[metric] = Object.entries(achievements).map(([key, achievement]) => {
293
+ if (hasCustomCondition(achievement)) {
294
+ // Custom condition function
295
+ return {
296
+ isConditionMet: (value, state) => {
297
+ // Convert internal metrics format (arrays) to simple format for custom conditions
298
+ const simpleMetrics = {};
299
+ Object.entries(state.metrics).forEach(([key, val]) => {
300
+ simpleMetrics[key] = Array.isArray(val) ? val[0] : val;
301
+ });
302
+ return achievement.condition(simpleMetrics);
303
+ },
304
+ achievementDetails: {
305
+ achievementId: `${metric}_custom_${generateId()}`,
306
+ achievementTitle: achievement.title,
307
+ achievementDescription: achievement.description || '',
308
+ achievementIconKey: achievement.icon || 'default'
309
+ }
310
+ };
311
+ }
312
+ else {
313
+ // Threshold-based achievement
314
+ const threshold = parseFloat(key);
315
+ const isValidThreshold = !isNaN(threshold);
316
+ let conditionMet;
317
+ if (isValidThreshold) {
318
+ // Numeric threshold
319
+ conditionMet = (value) => {
320
+ const numValue = Array.isArray(value) ? value[0] : value;
321
+ return typeof numValue === 'number' && numValue >= threshold;
322
+ };
323
+ }
324
+ else {
325
+ // String or boolean threshold
326
+ conditionMet = (value) => {
327
+ const actualValue = Array.isArray(value) ? value[0] : value;
328
+ // Handle boolean thresholds
329
+ if (key === 'true')
330
+ return actualValue === true;
331
+ if (key === 'false')
332
+ return actualValue === false;
333
+ // Handle string thresholds
334
+ return actualValue === key;
335
+ };
336
+ }
337
+ return {
338
+ isConditionMet: conditionMet,
339
+ achievementDetails: {
340
+ achievementId: `${metric}_${key}`,
341
+ achievementTitle: achievement.title,
342
+ achievementDescription: achievement.description || (isValidThreshold ? `Reach ${threshold} ${metric}` : `Achieve ${key} for ${metric}`),
343
+ achievementIconKey: achievement.icon || 'default'
344
+ }
345
+ };
346
+ }
347
+ });
348
+ });
349
+ return normalized;
340
350
  }
341
351
 
342
352
  const AchievementContext = createContext(undefined);
343
- const AchievementProvider = ({ achievements, storage = StorageType.Local, children, icons = {}, }) => {
353
+ const AchievementProvider = ({ achievements: achievementsConfig, storage = StorageType.Local, children, icons = {}, }) => {
354
+ // Normalize the configuration to the complex format
355
+ const achievements = normalizeAchievements(achievementsConfig);
344
356
  const [achievementState, setAchievementState] = useState({
345
357
  unlocked: [],
346
358
  all: achievements,
@@ -432,12 +444,16 @@ const AchievementProvider = ({ achievements, storage = StorageType.Local, childr
432
444
  let newlyUnlockedAchievements = [];
433
445
  let achievementToShow = null;
434
446
  Object.entries(achievements).forEach(([metricName, metricAchievements]) => {
435
- const currentValue = metrics[metricName];
436
- if (currentValue !== undefined) {
437
- metricAchievements.forEach((achievement) => {
438
- const state = { metrics, unlockedAchievements: achievementState.unlocked };
447
+ metricAchievements.forEach((achievement) => {
448
+ const state = { metrics, unlockedAchievements: achievementState.unlocked };
449
+ const achievementId = achievement.achievementDetails.achievementId;
450
+ // For custom conditions, we always check against all metrics
451
+ // For threshold-based conditions, we check against the specific metric
452
+ const currentValue = metrics[metricName];
453
+ const shouldCheckAchievement = currentValue !== undefined ||
454
+ achievement.achievementDetails.achievementId.includes('_custom_');
455
+ if (shouldCheckAchievement) {
439
456
  const valueToCheck = currentValue;
440
- const achievementId = achievement.achievementDetails.achievementId;
441
457
  if (achievement.isConditionMet(valueToCheck, state)) {
442
458
  if (!achievementState.unlocked.includes(achievementId) &&
443
459
  !newlyUnlockedAchievements.includes(achievementId)) {
@@ -447,8 +463,8 @@ const AchievementProvider = ({ achievements, storage = StorageType.Local, childr
447
463
  }
448
464
  }
449
465
  }
450
- });
451
- }
466
+ }
467
+ });
452
468
  });
453
469
  if (newlyUnlockedAchievements.length > 0) {
454
470
  const allUnlocked = [...achievementState.unlocked, ...newlyUnlockedAchievements];
@@ -550,6 +566,47 @@ const useAchievements = () => {
550
566
  return context;
551
567
  };
552
568
 
569
+ /**
570
+ * A simplified hook for achievement tracking.
571
+ * Provides an easier API for common use cases while maintaining access to advanced features.
572
+ */
573
+ const useSimpleAchievements = () => {
574
+ const { update, achievements, reset, getState } = useAchievements();
575
+ return {
576
+ /**
577
+ * Track a metric value for achievements
578
+ * @param metric - The metric name (e.g., 'score', 'level')
579
+ * @param value - The metric value
580
+ */
581
+ track: (metric, value) => update({ [metric]: value }),
582
+ /**
583
+ * Track multiple metrics at once
584
+ * @param metrics - Object with metric names as keys and values
585
+ */
586
+ trackMultiple: (metrics) => update(metrics),
587
+ /**
588
+ * Array of unlocked achievement IDs
589
+ */
590
+ unlocked: achievements.unlocked,
591
+ /**
592
+ * All available achievements
593
+ */
594
+ all: achievements.all,
595
+ /**
596
+ * Number of unlocked achievements
597
+ */
598
+ unlockedCount: achievements.unlocked.length,
599
+ /**
600
+ * Reset all achievement progress
601
+ */
602
+ reset,
603
+ /**
604
+ * Get current state (advanced usage)
605
+ */
606
+ getState,
607
+ };
608
+ };
609
+
553
610
  const defaultStyles = {
554
611
  badgesButton: {
555
612
  backgroundColor: '#4CAF50',
@@ -627,5 +684,291 @@ const defaultStyles = {
627
684
  },
628
685
  };
629
686
 
630
- export { AchievementContext, AchievementProvider, BadgesButton, BadgesModal, ConfettiWrapper, LocalStorage, StorageType, defaultAchievementIcons, defaultStyles, useAchievements };
687
+ /**
688
+ * Base class for chainable achievement configuration (Tier 2)
689
+ */
690
+ class Achievement {
691
+ constructor(metric, defaultAward) {
692
+ this.metric = metric;
693
+ this.award = defaultAward;
694
+ }
695
+ /**
696
+ * Customize the award details for this achievement
697
+ * @param award - Custom award details
698
+ * @returns This achievement for chaining
699
+ */
700
+ withAward(award) {
701
+ this.award = Object.assign(Object.assign({}, this.award), award);
702
+ return this;
703
+ }
704
+ }
705
+ /**
706
+ * Threshold-based achievement (score, level, etc.)
707
+ */
708
+ class ThresholdAchievement extends Achievement {
709
+ constructor(metric, threshold, defaultAward) {
710
+ super(metric, defaultAward);
711
+ this.threshold = threshold;
712
+ }
713
+ toConfig() {
714
+ return {
715
+ [this.metric]: {
716
+ [this.threshold]: {
717
+ title: this.award.title,
718
+ description: this.award.description,
719
+ icon: this.award.icon
720
+ }
721
+ }
722
+ };
723
+ }
724
+ }
725
+ /**
726
+ * Boolean achievement (tutorial completion, first login, etc.)
727
+ */
728
+ class BooleanAchievement extends Achievement {
729
+ toConfig() {
730
+ return {
731
+ [this.metric]: {
732
+ true: {
733
+ title: this.award.title,
734
+ description: this.award.description,
735
+ icon: this.award.icon
736
+ }
737
+ }
738
+ };
739
+ }
740
+ }
741
+ /**
742
+ * Value-based achievement (character class, difficulty, etc.)
743
+ */
744
+ class ValueAchievement extends Achievement {
745
+ constructor(metric, value, defaultAward) {
746
+ super(metric, defaultAward);
747
+ this.value = value;
748
+ }
749
+ toConfig() {
750
+ return {
751
+ [this.metric]: {
752
+ [this.value]: {
753
+ title: this.award.title,
754
+ description: this.award.description,
755
+ icon: this.award.icon
756
+ }
757
+ }
758
+ };
759
+ }
760
+ }
761
+ /**
762
+ * Complex achievement builder for power users (Tier 3)
763
+ */
764
+ class ComplexAchievementBuilder {
765
+ constructor() {
766
+ this.id = '';
767
+ this.metric = '';
768
+ this.condition = null;
769
+ this.award = {};
770
+ }
771
+ /**
772
+ * Set the unique identifier for this achievement
773
+ */
774
+ withId(id) {
775
+ this.id = id;
776
+ return this;
777
+ }
778
+ /**
779
+ * Set the metric this achievement tracks
780
+ */
781
+ withMetric(metric) {
782
+ this.metric = metric;
783
+ return this;
784
+ }
785
+ /**
786
+ * Set the condition function that determines if achievement is unlocked
787
+ */
788
+ withCondition(fn) {
789
+ this.condition = fn;
790
+ return this;
791
+ }
792
+ /**
793
+ * Set the award details for this achievement
794
+ */
795
+ withAward(award) {
796
+ this.award = Object.assign(Object.assign({}, this.award), award);
797
+ return this;
798
+ }
799
+ /**
800
+ * Build the final achievement configuration
801
+ */
802
+ build() {
803
+ if (!this.id || !this.metric || !this.condition) {
804
+ throw new Error('Complex achievement requires id, metric, and condition');
805
+ }
806
+ // Convert our two-parameter condition function to the single-parameter format
807
+ // expected by the existing CustomAchievementDetails type
808
+ const compatibleCondition = (metrics) => {
809
+ const state = {
810
+ metrics: {}, // We don't have access to the full metrics structure here
811
+ unlockedAchievements: []
812
+ };
813
+ return this.condition(metrics[this.metric], state);
814
+ };
815
+ return {
816
+ [this.id]: {
817
+ custom: {
818
+ title: this.award.title || this.id,
819
+ description: this.award.description || `Achieve ${this.award.title || this.id}`,
820
+ icon: this.award.icon || '💎',
821
+ condition: compatibleCondition
822
+ }
823
+ }
824
+ };
825
+ }
826
+ }
827
+ /**
828
+ * Main AchievementBuilder with three-tier API
829
+ * Tier 1: Simple static methods with smart defaults
830
+ * Tier 2: Chainable customization
831
+ * Tier 3: Full builder for complex logic
832
+ */
833
+ class AchievementBuilder {
834
+ // TIER 1: Simple Static Methods (90% of use cases)
835
+ /**
836
+ * Create a single score achievement with smart defaults
837
+ * @param threshold - Score threshold to achieve
838
+ * @returns Chainable ThresholdAchievement
839
+ */
840
+ static createScoreAchievement(threshold) {
841
+ return new ThresholdAchievement('score', threshold, {
842
+ title: `Score ${threshold}!`,
843
+ description: `Score ${threshold} points`,
844
+ icon: '🏆'
845
+ });
846
+ }
847
+ /**
848
+ * Create multiple score achievements
849
+ * @param thresholds - Array of thresholds or [threshold, award] tuples
850
+ * @returns Complete SimpleAchievementConfig
851
+ */
852
+ static createScoreAchievements(thresholds) {
853
+ const config = { score: {} };
854
+ thresholds.forEach(item => {
855
+ if (typeof item === 'number') {
856
+ // Use default award
857
+ config.score[item] = {
858
+ title: `Score ${item}!`,
859
+ description: `Score ${item} points`,
860
+ icon: '🏆'
861
+ };
862
+ }
863
+ else {
864
+ // Custom award
865
+ const [threshold, award] = item;
866
+ config.score[threshold] = {
867
+ title: award.title || `Score ${threshold}!`,
868
+ description: award.description || `Score ${threshold} points`,
869
+ icon: award.icon || '🏆'
870
+ };
871
+ }
872
+ });
873
+ return config;
874
+ }
875
+ /**
876
+ * Create a single level achievement with smart defaults
877
+ * @param level - Level threshold to achieve
878
+ * @returns Chainable ThresholdAchievement
879
+ */
880
+ static createLevelAchievement(level) {
881
+ return new ThresholdAchievement('level', level, {
882
+ title: `Level ${level}!`,
883
+ description: `Reach level ${level}`,
884
+ icon: '📈'
885
+ });
886
+ }
887
+ /**
888
+ * Create multiple level achievements
889
+ * @param levels - Array of levels or [level, award] tuples
890
+ * @returns Complete SimpleAchievementConfig
891
+ */
892
+ static createLevelAchievements(levels) {
893
+ const config = { level: {} };
894
+ levels.forEach(item => {
895
+ if (typeof item === 'number') {
896
+ // Use default award
897
+ config.level[item] = {
898
+ title: `Level ${item}!`,
899
+ description: `Reach level ${item}`,
900
+ icon: '📈'
901
+ };
902
+ }
903
+ else {
904
+ // Custom award
905
+ const [level, award] = item;
906
+ config.level[level] = {
907
+ title: award.title || `Level ${level}!`,
908
+ description: award.description || `Reach level ${level}`,
909
+ icon: award.icon || '📈'
910
+ };
911
+ }
912
+ });
913
+ return config;
914
+ }
915
+ /**
916
+ * Create a boolean achievement with smart defaults
917
+ * @param metric - The metric name (e.g., 'completedTutorial')
918
+ * @returns Chainable BooleanAchievement
919
+ */
920
+ static createBooleanAchievement(metric) {
921
+ // Convert camelCase to Title Case
922
+ const formattedMetric = metric.replace(/([A-Z])/g, ' $1').toLowerCase();
923
+ const titleCase = formattedMetric.charAt(0).toUpperCase() + formattedMetric.slice(1);
924
+ return new BooleanAchievement(metric, {
925
+ title: `${titleCase}!`,
926
+ description: `Complete ${formattedMetric}`,
927
+ icon: '✅'
928
+ });
929
+ }
930
+ /**
931
+ * Create a value-based achievement with smart defaults
932
+ * @param metric - The metric name (e.g., 'characterClass')
933
+ * @param value - The value to match (e.g., 'wizard')
934
+ * @returns Chainable ValueAchievement
935
+ */
936
+ static createValueAchievement(metric, value) {
937
+ const formattedValue = value.charAt(0).toUpperCase() + value.slice(1);
938
+ return new ValueAchievement(metric, value, {
939
+ title: `${formattedValue}!`,
940
+ description: `Choose ${formattedValue.toLowerCase()} for ${metric}`,
941
+ icon: '🎯'
942
+ });
943
+ }
944
+ // TIER 3: Full Builder for Complex Logic
945
+ /**
946
+ * Create a complex achievement builder for power users
947
+ * @returns ComplexAchievementBuilder for full control
948
+ */
949
+ static create() {
950
+ return new ComplexAchievementBuilder();
951
+ }
952
+ // UTILITY METHODS
953
+ /**
954
+ * Combine multiple achievement configurations
955
+ * @param achievements - Array of SimpleAchievementConfig objects or Achievement instances
956
+ * @returns Combined SimpleAchievementConfig
957
+ */
958
+ static combine(achievements) {
959
+ const combined = {};
960
+ achievements.forEach(achievement => {
961
+ const config = achievement instanceof Achievement ? achievement.toConfig() : achievement;
962
+ Object.keys(config).forEach(key => {
963
+ if (!combined[key]) {
964
+ combined[key] = {};
965
+ }
966
+ Object.assign(combined[key], config[key]);
967
+ });
968
+ });
969
+ return combined;
970
+ }
971
+ }
972
+
973
+ export { AchievementBuilder, AchievementContext, AchievementProvider, BadgesButton, BadgesModal, ConfettiWrapper, LocalStorage, MemoryStorage, StorageType, defaultAchievementIcons, defaultStyles, isSimpleConfig, normalizeAchievements, useAchievements, useSimpleAchievements };
631
974
  //# sourceMappingURL=index.js.map