react-achievements 3.2.1 → 3.4.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
@@ -8,12 +8,100 @@ import 'react-toastify/dist/ReactToastify.css';
8
8
  const isDate = (value) => {
9
9
  return value instanceof Date;
10
10
  };
11
+ // Type guard to detect async storage
12
+ function isAsyncStorage(storage) {
13
+ // Check if methods return Promises
14
+ const testResult = storage.getMetrics();
15
+ return testResult && typeof testResult.then === 'function';
16
+ }
11
17
  var StorageType;
12
18
  (function (StorageType) {
13
19
  StorageType["Local"] = "local";
14
20
  StorageType["Memory"] = "memory";
21
+ StorageType["IndexedDB"] = "indexeddb";
22
+ StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
15
23
  })(StorageType || (StorageType = {}));
16
24
 
25
+ /**
26
+ * Base error class for all achievement-related errors
27
+ */
28
+ class AchievementError extends Error {
29
+ constructor(message, code, recoverable, remedy) {
30
+ super(message);
31
+ this.code = code;
32
+ this.recoverable = recoverable;
33
+ this.remedy = remedy;
34
+ this.name = 'AchievementError';
35
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
36
+ if (Error.captureStackTrace) {
37
+ Error.captureStackTrace(this, AchievementError);
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Error thrown when browser storage quota is exceeded
43
+ */
44
+ class StorageQuotaError extends AchievementError {
45
+ constructor(bytesNeeded) {
46
+ super('Browser storage quota exceeded. Achievement data could not be saved.', 'STORAGE_QUOTA_EXCEEDED', true, 'Clear browser storage, reduce the number of achievements, or use an external database backend. You can export your current data using exportData() before clearing storage.');
47
+ this.bytesNeeded = bytesNeeded;
48
+ this.name = 'StorageQuotaError';
49
+ }
50
+ }
51
+ /**
52
+ * Error thrown when imported data fails validation
53
+ */
54
+ class ImportValidationError extends AchievementError {
55
+ constructor(validationErrors) {
56
+ super(`Imported data failed validation: ${validationErrors.join(', ')}`, 'IMPORT_VALIDATION_ERROR', true, 'Check that the imported data was exported from a compatible version and matches your current achievement configuration.');
57
+ this.validationErrors = validationErrors;
58
+ this.name = 'ImportValidationError';
59
+ }
60
+ }
61
+ /**
62
+ * Error thrown when storage operations fail
63
+ */
64
+ class StorageError extends AchievementError {
65
+ constructor(message, originalError) {
66
+ super(message, 'STORAGE_ERROR', true, 'Check browser storage permissions and available space. If using custom storage, verify the implementation is correct.');
67
+ this.originalError = originalError;
68
+ this.name = 'StorageError';
69
+ }
70
+ }
71
+ /**
72
+ * Error thrown when configuration is invalid
73
+ */
74
+ class ConfigurationError extends AchievementError {
75
+ constructor(message) {
76
+ super(message, 'CONFIGURATION_ERROR', false, 'Review your achievement configuration and ensure it follows the correct format.');
77
+ this.name = 'ConfigurationError';
78
+ }
79
+ }
80
+ /**
81
+ * Error thrown when network sync operations fail
82
+ */
83
+ class SyncError extends AchievementError {
84
+ constructor(message, details) {
85
+ super(message, 'SYNC_ERROR', true, // recoverable (can retry)
86
+ 'Check your network connection and try again. If the problem persists, achievements will sync when connection is restored.');
87
+ this.name = 'SyncError';
88
+ this.statusCode = details === null || details === void 0 ? void 0 : details.statusCode;
89
+ this.timeout = details === null || details === void 0 ? void 0 : details.timeout;
90
+ }
91
+ }
92
+ /**
93
+ * Type guard to check if an error is an AchievementError
94
+ */
95
+ function isAchievementError(error) {
96
+ return error instanceof AchievementError;
97
+ }
98
+ /**
99
+ * Type guard to check if an error is recoverable
100
+ */
101
+ function isRecoverableError(error) {
102
+ return isAchievementError(error) && error.recoverable;
103
+ }
104
+
17
105
  class LocalStorage {
18
106
  constructor(storageKey) {
19
107
  this.storageKey = storageKey;
@@ -67,17 +155,33 @@ class LocalStorage {
67
155
  metrics: this.serializeMetrics(data.metrics),
68
156
  unlockedAchievements: data.unlockedAchievements
69
157
  };
70
- localStorage.setItem(this.storageKey, JSON.stringify(serialized));
158
+ const jsonString = JSON.stringify(serialized);
159
+ localStorage.setItem(this.storageKey, jsonString);
71
160
  }
72
161
  catch (error) {
73
- // Silently fail on quota exceeded errors
74
- if (error instanceof Error &&
162
+ // Throw proper error instead of silently failing
163
+ if (error instanceof DOMException &&
75
164
  (error.name === 'QuotaExceededError' ||
76
- error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
77
- error.message.includes('QuotaExceeded'))) {
78
- return;
165
+ error.name === 'NS_ERROR_DOM_QUOTA_REACHED')) {
166
+ const serialized = {
167
+ metrics: this.serializeMetrics(data.metrics),
168
+ unlockedAchievements: data.unlockedAchievements
169
+ };
170
+ const bytesNeeded = JSON.stringify(serialized).length;
171
+ throw new StorageQuotaError(bytesNeeded);
172
+ }
173
+ if (error instanceof Error) {
174
+ if (error.message && error.message.includes('QuotaExceeded')) {
175
+ const serialized = {
176
+ metrics: this.serializeMetrics(data.metrics),
177
+ unlockedAchievements: data.unlockedAchievements
178
+ };
179
+ const bytesNeeded = JSON.stringify(serialized).length;
180
+ throw new StorageQuotaError(bytesNeeded);
181
+ }
182
+ throw new StorageError(`Failed to save achievement data: ${error.message}`, error);
79
183
  }
80
- throw error;
184
+ throw new StorageError('Failed to save achievement data');
81
185
  }
82
186
  }
83
187
  getMetrics() {
@@ -122,6 +226,641 @@ class MemoryStorage {
122
226
  }
123
227
  }
124
228
 
229
+ /******************************************************************************
230
+ Copyright (c) Microsoft Corporation.
231
+
232
+ Permission to use, copy, modify, and/or distribute this software for any
233
+ purpose with or without fee is hereby granted.
234
+
235
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
236
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
237
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
238
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
239
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
240
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
241
+ PERFORMANCE OF THIS SOFTWARE.
242
+ ***************************************************************************** */
243
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
244
+
245
+
246
+ function __awaiter(thisArg, _arguments, P, generator) {
247
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
248
+ return new (P || (P = Promise))(function (resolve, reject) {
249
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
250
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
251
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
252
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
253
+ });
254
+ }
255
+
256
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
257
+ var e = new Error(message);
258
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
259
+ };
260
+
261
+ class AsyncStorageAdapter {
262
+ constructor(asyncStorage, options) {
263
+ this.pendingWrites = [];
264
+ this.asyncStorage = asyncStorage;
265
+ this.onError = options === null || options === void 0 ? void 0 : options.onError;
266
+ this.cache = {
267
+ metrics: {},
268
+ unlocked: [],
269
+ loaded: false
270
+ };
271
+ // Eagerly load data from async storage (non-blocking)
272
+ this.initializeCache();
273
+ }
274
+ /**
275
+ * Initialize cache by loading from async storage
276
+ * This happens in the background during construction
277
+ */
278
+ initializeCache() {
279
+ return __awaiter(this, void 0, void 0, function* () {
280
+ try {
281
+ const [metrics, unlocked] = yield Promise.all([
282
+ this.asyncStorage.getMetrics(),
283
+ this.asyncStorage.getUnlockedAchievements()
284
+ ]);
285
+ this.cache.metrics = metrics;
286
+ this.cache.unlocked = unlocked;
287
+ this.cache.loaded = true;
288
+ }
289
+ catch (error) {
290
+ // Handle initialization errors
291
+ console.error('Failed to initialize async storage:', error);
292
+ if (this.onError) {
293
+ const storageError = error instanceof AchievementError
294
+ ? error
295
+ : new StorageError('Failed to initialize storage', error);
296
+ this.onError(storageError);
297
+ }
298
+ // Set to empty state on error
299
+ this.cache.loaded = true; // Mark as loaded even on error to prevent blocking
300
+ }
301
+ });
302
+ }
303
+ /**
304
+ * Wait for cache to be loaded (used internally)
305
+ * Returns immediately if already loaded, otherwise waits
306
+ */
307
+ ensureCacheLoaded() {
308
+ return __awaiter(this, void 0, void 0, function* () {
309
+ while (!this.cache.loaded) {
310
+ yield new Promise(resolve => setTimeout(resolve, 10));
311
+ }
312
+ });
313
+ }
314
+ /**
315
+ * SYNC READ: Returns cached metrics immediately
316
+ * Cache is loaded eagerly during construction
317
+ */
318
+ getMetrics() {
319
+ return this.cache.metrics;
320
+ }
321
+ /**
322
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
323
+ * Uses optimistic updates - assumes write will succeed
324
+ */
325
+ setMetrics(metrics) {
326
+ // Update cache immediately (optimistic update)
327
+ this.cache.metrics = metrics;
328
+ // Write to async storage in background
329
+ const writePromise = this.asyncStorage.setMetrics(metrics).catch(error => {
330
+ console.error('Failed to write metrics to async storage:', error);
331
+ if (this.onError) {
332
+ const storageError = error instanceof AchievementError
333
+ ? error
334
+ : new StorageError('Failed to write metrics', error);
335
+ this.onError(storageError);
336
+ }
337
+ });
338
+ // Track pending write for cleanup/testing
339
+ this.pendingWrites.push(writePromise);
340
+ }
341
+ /**
342
+ * SYNC READ: Returns cached unlocked achievements immediately
343
+ */
344
+ getUnlockedAchievements() {
345
+ return this.cache.unlocked;
346
+ }
347
+ /**
348
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
349
+ */
350
+ setUnlockedAchievements(achievements) {
351
+ // Update cache immediately (optimistic update)
352
+ this.cache.unlocked = achievements;
353
+ // Write to async storage in background
354
+ const writePromise = this.asyncStorage.setUnlockedAchievements(achievements).catch(error => {
355
+ console.error('Failed to write unlocked achievements to async storage:', error);
356
+ if (this.onError) {
357
+ const storageError = error instanceof AchievementError
358
+ ? error
359
+ : new StorageError('Failed to write achievements', error);
360
+ this.onError(storageError);
361
+ }
362
+ });
363
+ // Track pending write
364
+ this.pendingWrites.push(writePromise);
365
+ }
366
+ /**
367
+ * SYNC CLEAR: Clears cache immediately, clears storage in background
368
+ */
369
+ clear() {
370
+ // Clear cache immediately
371
+ this.cache.metrics = {};
372
+ this.cache.unlocked = [];
373
+ // Clear async storage in background
374
+ const clearPromise = this.asyncStorage.clear().catch(error => {
375
+ console.error('Failed to clear async storage:', error);
376
+ if (this.onError) {
377
+ const storageError = error instanceof AchievementError
378
+ ? error
379
+ : new StorageError('Failed to clear storage', error);
380
+ this.onError(storageError);
381
+ }
382
+ });
383
+ // Track pending write
384
+ this.pendingWrites.push(clearPromise);
385
+ }
386
+ /**
387
+ * Wait for all pending writes to complete (useful for testing/cleanup)
388
+ * NOT part of AchievementStorage interface - utility method
389
+ */
390
+ flush() {
391
+ return __awaiter(this, void 0, void 0, function* () {
392
+ yield Promise.all(this.pendingWrites);
393
+ this.pendingWrites = [];
394
+ });
395
+ }
396
+ }
397
+
398
+ class IndexedDBStorage {
399
+ constructor(dbName = 'react-achievements') {
400
+ this.storeName = 'achievements';
401
+ this.db = null;
402
+ this.dbName = dbName;
403
+ this.initPromise = this.initDB();
404
+ }
405
+ /**
406
+ * Initialize IndexedDB database and object store
407
+ */
408
+ initDB() {
409
+ return __awaiter(this, void 0, void 0, function* () {
410
+ return new Promise((resolve, reject) => {
411
+ const request = indexedDB.open(this.dbName, 1);
412
+ request.onerror = () => {
413
+ reject(new StorageError('Failed to open IndexedDB'));
414
+ };
415
+ request.onsuccess = () => {
416
+ this.db = request.result;
417
+ resolve();
418
+ };
419
+ request.onupgradeneeded = (event) => {
420
+ const db = event.target.result;
421
+ // Create object store if it doesn't exist
422
+ if (!db.objectStoreNames.contains(this.storeName)) {
423
+ db.createObjectStore(this.storeName);
424
+ }
425
+ };
426
+ });
427
+ });
428
+ }
429
+ /**
430
+ * Generic get operation from IndexedDB
431
+ */
432
+ get(key) {
433
+ return __awaiter(this, void 0, void 0, function* () {
434
+ yield this.initPromise;
435
+ if (!this.db)
436
+ throw new StorageError('Database not initialized');
437
+ return new Promise((resolve, reject) => {
438
+ const transaction = this.db.transaction([this.storeName], 'readonly');
439
+ const store = transaction.objectStore(this.storeName);
440
+ const request = store.get(key);
441
+ request.onsuccess = () => {
442
+ resolve(request.result || null);
443
+ };
444
+ request.onerror = () => {
445
+ reject(new StorageError(`Failed to read from IndexedDB: ${key}`));
446
+ };
447
+ });
448
+ });
449
+ }
450
+ /**
451
+ * Generic set operation to IndexedDB
452
+ */
453
+ set(key, value) {
454
+ return __awaiter(this, void 0, void 0, function* () {
455
+ yield this.initPromise;
456
+ if (!this.db)
457
+ throw new StorageError('Database not initialized');
458
+ return new Promise((resolve, reject) => {
459
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
460
+ const store = transaction.objectStore(this.storeName);
461
+ const request = store.put(value, key);
462
+ request.onsuccess = () => {
463
+ resolve();
464
+ };
465
+ request.onerror = () => {
466
+ reject(new StorageError(`Failed to write to IndexedDB: ${key}`));
467
+ };
468
+ });
469
+ });
470
+ }
471
+ /**
472
+ * Delete operation from IndexedDB
473
+ */
474
+ delete(key) {
475
+ return __awaiter(this, void 0, void 0, function* () {
476
+ yield this.initPromise;
477
+ if (!this.db)
478
+ throw new StorageError('Database not initialized');
479
+ return new Promise((resolve, reject) => {
480
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
481
+ const store = transaction.objectStore(this.storeName);
482
+ const request = store.delete(key);
483
+ request.onsuccess = () => {
484
+ resolve();
485
+ };
486
+ request.onerror = () => {
487
+ reject(new StorageError(`Failed to delete from IndexedDB: ${key}`));
488
+ };
489
+ });
490
+ });
491
+ }
492
+ getMetrics() {
493
+ return __awaiter(this, void 0, void 0, function* () {
494
+ const metrics = yield this.get('metrics');
495
+ return metrics || {};
496
+ });
497
+ }
498
+ setMetrics(metrics) {
499
+ return __awaiter(this, void 0, void 0, function* () {
500
+ yield this.set('metrics', metrics);
501
+ });
502
+ }
503
+ getUnlockedAchievements() {
504
+ return __awaiter(this, void 0, void 0, function* () {
505
+ const unlocked = yield this.get('unlocked');
506
+ return unlocked || [];
507
+ });
508
+ }
509
+ setUnlockedAchievements(achievements) {
510
+ return __awaiter(this, void 0, void 0, function* () {
511
+ yield this.set('unlocked', achievements);
512
+ });
513
+ }
514
+ clear() {
515
+ return __awaiter(this, void 0, void 0, function* () {
516
+ yield Promise.all([
517
+ this.delete('metrics'),
518
+ this.delete('unlocked')
519
+ ]);
520
+ });
521
+ }
522
+ }
523
+
524
+ class RestApiStorage {
525
+ constructor(config) {
526
+ this.config = Object.assign({ timeout: 10000, headers: {} }, config);
527
+ }
528
+ /**
529
+ * Generic fetch wrapper with timeout and error handling
530
+ */
531
+ fetchWithTimeout(url, options) {
532
+ return __awaiter(this, void 0, void 0, function* () {
533
+ const controller = new AbortController();
534
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
535
+ try {
536
+ const response = yield fetch(url, Object.assign(Object.assign({}, options), { headers: Object.assign(Object.assign({ 'Content-Type': 'application/json' }, this.config.headers), options.headers), signal: controller.signal }));
537
+ clearTimeout(timeoutId);
538
+ if (!response.ok) {
539
+ throw new SyncError(`HTTP ${response.status}: ${response.statusText}`, { statusCode: response.status });
540
+ }
541
+ return response;
542
+ }
543
+ catch (error) {
544
+ clearTimeout(timeoutId);
545
+ if (error instanceof Error && error.name === 'AbortError') {
546
+ throw new SyncError('Request timeout', { timeout: this.config.timeout });
547
+ }
548
+ throw error;
549
+ }
550
+ });
551
+ }
552
+ getMetrics() {
553
+ return __awaiter(this, void 0, void 0, function* () {
554
+ var _a;
555
+ try {
556
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
557
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
558
+ const data = yield response.json();
559
+ return data.metrics || {};
560
+ }
561
+ catch (error) {
562
+ // Re-throw SyncError and other AchievementErrors (but not StorageError)
563
+ // Multiple checks for Jest compatibility
564
+ const err = error;
565
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError') {
566
+ throw error;
567
+ }
568
+ // Also check instanceof for normal cases
569
+ if (error instanceof AchievementError && !(error instanceof StorageError)) {
570
+ throw error;
571
+ }
572
+ throw new StorageError('Failed to fetch metrics from API', error);
573
+ }
574
+ });
575
+ }
576
+ setMetrics(metrics) {
577
+ return __awaiter(this, void 0, void 0, function* () {
578
+ var _a;
579
+ try {
580
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
581
+ yield this.fetchWithTimeout(url, {
582
+ method: 'PUT',
583
+ body: JSON.stringify({ metrics })
584
+ });
585
+ }
586
+ catch (error) {
587
+ const err = error;
588
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
589
+ throw error;
590
+ if (error instanceof AchievementError && !(error instanceof StorageError))
591
+ throw error;
592
+ throw new StorageError('Failed to save metrics to API', error);
593
+ }
594
+ });
595
+ }
596
+ getUnlockedAchievements() {
597
+ return __awaiter(this, void 0, void 0, function* () {
598
+ var _a;
599
+ try {
600
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
601
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
602
+ const data = yield response.json();
603
+ return data.unlocked || [];
604
+ }
605
+ catch (error) {
606
+ const err = error;
607
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
608
+ throw error;
609
+ if (error instanceof AchievementError && !(error instanceof StorageError))
610
+ throw error;
611
+ throw new StorageError('Failed to fetch unlocked achievements from API', error);
612
+ }
613
+ });
614
+ }
615
+ setUnlockedAchievements(achievements) {
616
+ return __awaiter(this, void 0, void 0, function* () {
617
+ var _a;
618
+ try {
619
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
620
+ yield this.fetchWithTimeout(url, {
621
+ method: 'PUT',
622
+ body: JSON.stringify({ unlocked: achievements })
623
+ });
624
+ }
625
+ catch (error) {
626
+ const err = error;
627
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
628
+ throw error;
629
+ if (error instanceof AchievementError && !(error instanceof StorageError))
630
+ throw error;
631
+ throw new StorageError('Failed to save unlocked achievements to API', error);
632
+ }
633
+ });
634
+ }
635
+ clear() {
636
+ return __awaiter(this, void 0, void 0, function* () {
637
+ var _a;
638
+ try {
639
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements`;
640
+ yield this.fetchWithTimeout(url, { method: 'DELETE' });
641
+ }
642
+ catch (error) {
643
+ const err = error;
644
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
645
+ throw error;
646
+ if (error instanceof AchievementError && !(error instanceof StorageError))
647
+ throw error;
648
+ throw new StorageError('Failed to clear achievements via API', error);
649
+ }
650
+ });
651
+ }
652
+ }
653
+
654
+ class OfflineQueueStorage {
655
+ constructor(innerStorage) {
656
+ this.queue = [];
657
+ this.isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
658
+ this.isSyncing = false;
659
+ this.queueStorageKey = 'achievements_offline_queue';
660
+ this.handleOnline = () => {
661
+ this.isOnline = true;
662
+ console.log('[OfflineQueue] Back online, processing queue...');
663
+ this.processQueue();
664
+ };
665
+ this.handleOffline = () => {
666
+ this.isOnline = false;
667
+ console.log('[OfflineQueue] Offline mode activated');
668
+ };
669
+ this.innerStorage = innerStorage;
670
+ // Load queued operations from localStorage
671
+ this.loadQueue();
672
+ // Listen for online/offline events (only in browser environment)
673
+ if (typeof window !== 'undefined') {
674
+ window.addEventListener('online', this.handleOnline);
675
+ window.addEventListener('offline', this.handleOffline);
676
+ }
677
+ // Process queue if already online
678
+ if (this.isOnline) {
679
+ this.processQueue();
680
+ }
681
+ }
682
+ loadQueue() {
683
+ try {
684
+ if (typeof localStorage !== 'undefined') {
685
+ const queueData = localStorage.getItem(this.queueStorageKey);
686
+ if (queueData) {
687
+ this.queue = JSON.parse(queueData);
688
+ }
689
+ }
690
+ }
691
+ catch (error) {
692
+ console.error('Failed to load offline queue:', error);
693
+ this.queue = [];
694
+ }
695
+ }
696
+ saveQueue() {
697
+ try {
698
+ if (typeof localStorage !== 'undefined') {
699
+ localStorage.setItem(this.queueStorageKey, JSON.stringify(this.queue));
700
+ }
701
+ }
702
+ catch (error) {
703
+ console.error('Failed to save offline queue:', error);
704
+ }
705
+ }
706
+ processQueue() {
707
+ return __awaiter(this, void 0, void 0, function* () {
708
+ if (this.isSyncing || this.queue.length === 0 || !this.isOnline) {
709
+ return;
710
+ }
711
+ this.isSyncing = true;
712
+ try {
713
+ // Process operations in order
714
+ while (this.queue.length > 0 && this.isOnline) {
715
+ const operation = this.queue[0];
716
+ try {
717
+ switch (operation.type) {
718
+ case 'setMetrics':
719
+ yield this.innerStorage.setMetrics(operation.data);
720
+ break;
721
+ case 'setUnlockedAchievements':
722
+ yield this.innerStorage.setUnlockedAchievements(operation.data);
723
+ break;
724
+ case 'clear':
725
+ yield this.innerStorage.clear();
726
+ break;
727
+ }
728
+ // Operation succeeded, remove from queue
729
+ this.queue.shift();
730
+ this.saveQueue();
731
+ }
732
+ catch (error) {
733
+ console.error('Failed to sync queued operation:', error);
734
+ // Stop processing on error, will retry later
735
+ break;
736
+ }
737
+ }
738
+ }
739
+ finally {
740
+ this.isSyncing = false;
741
+ }
742
+ });
743
+ }
744
+ queueOperation(type, data) {
745
+ const operation = {
746
+ id: `${Date.now()}_${Math.random()}`,
747
+ type,
748
+ data,
749
+ timestamp: Date.now()
750
+ };
751
+ this.queue.push(operation);
752
+ this.saveQueue();
753
+ // Try to process queue if online
754
+ if (this.isOnline) {
755
+ this.processQueue();
756
+ }
757
+ }
758
+ getMetrics() {
759
+ return __awaiter(this, void 0, void 0, function* () {
760
+ // Reads always try to hit the server first
761
+ try {
762
+ return yield this.innerStorage.getMetrics();
763
+ }
764
+ catch (error) {
765
+ if (!this.isOnline) {
766
+ throw new StorageError('Cannot read metrics while offline');
767
+ }
768
+ throw error;
769
+ }
770
+ });
771
+ }
772
+ setMetrics(metrics) {
773
+ return __awaiter(this, void 0, void 0, function* () {
774
+ if (this.isOnline) {
775
+ try {
776
+ yield this.innerStorage.setMetrics(metrics);
777
+ return;
778
+ }
779
+ catch (error) {
780
+ // Failed while online, queue it
781
+ console.warn('Failed to set metrics, queuing for later:', error);
782
+ }
783
+ }
784
+ // Queue operation if offline or if online operation failed
785
+ this.queueOperation('setMetrics', metrics);
786
+ });
787
+ }
788
+ getUnlockedAchievements() {
789
+ return __awaiter(this, void 0, void 0, function* () {
790
+ // Reads always try to hit the server first
791
+ try {
792
+ return yield this.innerStorage.getUnlockedAchievements();
793
+ }
794
+ catch (error) {
795
+ if (!this.isOnline) {
796
+ throw new StorageError('Cannot read achievements while offline');
797
+ }
798
+ throw error;
799
+ }
800
+ });
801
+ }
802
+ setUnlockedAchievements(achievements) {
803
+ return __awaiter(this, void 0, void 0, function* () {
804
+ if (this.isOnline) {
805
+ try {
806
+ yield this.innerStorage.setUnlockedAchievements(achievements);
807
+ return;
808
+ }
809
+ catch (error) {
810
+ // Failed while online, queue it
811
+ console.warn('Failed to set unlocked achievements, queuing for later:', error);
812
+ }
813
+ }
814
+ // Queue operation if offline or if online operation failed
815
+ this.queueOperation('setUnlockedAchievements', achievements);
816
+ });
817
+ }
818
+ clear() {
819
+ return __awaiter(this, void 0, void 0, function* () {
820
+ if (this.isOnline) {
821
+ try {
822
+ yield this.innerStorage.clear();
823
+ // Also clear the queue
824
+ this.queue = [];
825
+ this.saveQueue();
826
+ return;
827
+ }
828
+ catch (error) {
829
+ console.warn('Failed to clear, queuing for later:', error);
830
+ }
831
+ }
832
+ // Queue operation if offline or if online operation failed
833
+ this.queueOperation('clear');
834
+ });
835
+ }
836
+ /**
837
+ * Manually trigger queue processing (useful for testing)
838
+ */
839
+ sync() {
840
+ return __awaiter(this, void 0, void 0, function* () {
841
+ yield this.processQueue();
842
+ });
843
+ }
844
+ /**
845
+ * Get current queue status (useful for debugging)
846
+ */
847
+ getQueueStatus() {
848
+ return {
849
+ pending: this.queue.length,
850
+ operations: [...this.queue]
851
+ };
852
+ }
853
+ /**
854
+ * Cleanup listeners (call on unmount)
855
+ */
856
+ destroy() {
857
+ if (typeof window !== 'undefined') {
858
+ window.removeEventListener('online', this.handleOnline);
859
+ window.removeEventListener('offline', this.handleOffline);
860
+ }
861
+ }
862
+ }
863
+
125
864
  const getPositionStyles = (position) => {
126
865
  const base = {
127
866
  position: 'fixed',
@@ -349,8 +1088,219 @@ function normalizeAchievements(config) {
349
1088
  return normalized;
350
1089
  }
351
1090
 
1091
+ /**
1092
+ * Exports achievement data to a JSON string
1093
+ *
1094
+ * @param metrics - Current achievement metrics
1095
+ * @param unlocked - Array of unlocked achievement IDs
1096
+ * @param configHash - Optional hash of achievement configuration for validation
1097
+ * @returns JSON string containing all achievement data
1098
+ *
1099
+ * @example
1100
+ * ```typescript
1101
+ * const json = exportAchievementData(metrics, ['score_100', 'level_5']);
1102
+ * // Save json to file or send to server
1103
+ * ```
1104
+ */
1105
+ function exportAchievementData(metrics, unlocked, configHash) {
1106
+ const data = Object.assign({ version: '3.3.0', timestamp: Date.now(), metrics, unlockedAchievements: unlocked }, (configHash && { configHash }));
1107
+ return JSON.stringify(data, null, 2);
1108
+ }
1109
+ /**
1110
+ * Creates a simple hash of the achievement configuration
1111
+ * Used to validate that imported data matches the current configuration
1112
+ *
1113
+ * @param config - Achievement configuration object
1114
+ * @returns Simple hash string
1115
+ */
1116
+ function createConfigHash(config) {
1117
+ // Simple hash based on stringified config
1118
+ // In production, you might want to use a more robust hashing algorithm
1119
+ const str = JSON.stringify(config);
1120
+ let hash = 0;
1121
+ for (let i = 0; i < str.length; i++) {
1122
+ const char = str.charCodeAt(i);
1123
+ hash = ((hash << 5) - hash) + char;
1124
+ hash = hash & hash; // Convert to 32bit integer
1125
+ }
1126
+ return hash.toString(36);
1127
+ }
1128
+
1129
+ /**
1130
+ * Imports achievement data from a JSON string
1131
+ *
1132
+ * @param jsonString - JSON string containing exported achievement data
1133
+ * @param currentMetrics - Current metrics state
1134
+ * @param currentUnlocked - Current unlocked achievements
1135
+ * @param options - Import options
1136
+ * @returns Import result with success status and any errors
1137
+ *
1138
+ * @example
1139
+ * ```typescript
1140
+ * const result = importAchievementData(
1141
+ * jsonString,
1142
+ * currentMetrics,
1143
+ * currentUnlocked,
1144
+ * { mergeStrategy: 'merge', validate: true }
1145
+ * );
1146
+ *
1147
+ * if (result.success) {
1148
+ * console.log(`Imported ${result.imported.achievements} achievements`);
1149
+ * } else {
1150
+ * console.error('Import failed:', result.errors);
1151
+ * }
1152
+ * ```
1153
+ */
1154
+ function importAchievementData(jsonString, currentMetrics, currentUnlocked, options = {}) {
1155
+ const { mergeStrategy = 'replace', validate = true, expectedConfigHash } = options;
1156
+ const warnings = [];
1157
+ // Parse JSON
1158
+ let data;
1159
+ try {
1160
+ data = JSON.parse(jsonString);
1161
+ }
1162
+ catch (error) {
1163
+ return {
1164
+ success: false,
1165
+ imported: { metrics: 0, achievements: 0 },
1166
+ errors: ['Invalid JSON format']
1167
+ };
1168
+ }
1169
+ // Validate structure
1170
+ if (validate) {
1171
+ const validationErrors = validateExportedData(data, expectedConfigHash);
1172
+ if (validationErrors.length > 0) {
1173
+ return {
1174
+ success: false,
1175
+ imported: { metrics: 0, achievements: 0 },
1176
+ errors: validationErrors
1177
+ };
1178
+ }
1179
+ }
1180
+ // Version compatibility check
1181
+ if (data.version && data.version !== '3.3.0') {
1182
+ warnings.push(`Data exported from version ${data.version}, current version is 3.3.0`);
1183
+ }
1184
+ // Merge metrics based on strategy
1185
+ let mergedMetrics;
1186
+ let mergedUnlocked;
1187
+ switch (mergeStrategy) {
1188
+ case 'replace':
1189
+ // Replace all existing data
1190
+ mergedMetrics = data.metrics;
1191
+ mergedUnlocked = data.unlockedAchievements;
1192
+ break;
1193
+ case 'merge':
1194
+ // Union of both datasets, keeping higher metric values
1195
+ mergedMetrics = mergeMetrics(currentMetrics, data.metrics);
1196
+ mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
1197
+ break;
1198
+ case 'preserve':
1199
+ // Keep existing values, only add new ones
1200
+ mergedMetrics = preserveMetrics(currentMetrics, data.metrics);
1201
+ mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
1202
+ break;
1203
+ default:
1204
+ return {
1205
+ success: false,
1206
+ imported: { metrics: 0, achievements: 0 },
1207
+ errors: [`Invalid merge strategy: ${mergeStrategy}`]
1208
+ };
1209
+ }
1210
+ return Object.assign(Object.assign({ success: true, imported: {
1211
+ metrics: Object.keys(mergedMetrics).length,
1212
+ achievements: mergedUnlocked.length
1213
+ } }, (warnings.length > 0 && { warnings })), { mergedMetrics,
1214
+ mergedUnlocked });
1215
+ }
1216
+ /**
1217
+ * Validates the structure and content of exported data
1218
+ */
1219
+ function validateExportedData(data, expectedConfigHash) {
1220
+ const errors = [];
1221
+ // Check required fields
1222
+ if (!data.version) {
1223
+ errors.push('Missing version field');
1224
+ }
1225
+ if (!data.timestamp) {
1226
+ errors.push('Missing timestamp field');
1227
+ }
1228
+ if (!data.metrics || typeof data.metrics !== 'object') {
1229
+ errors.push('Missing or invalid metrics field');
1230
+ }
1231
+ if (!Array.isArray(data.unlockedAchievements)) {
1232
+ errors.push('Missing or invalid unlockedAchievements field');
1233
+ }
1234
+ // Validate config hash if provided
1235
+ if (expectedConfigHash && data.configHash && data.configHash !== expectedConfigHash) {
1236
+ errors.push('Configuration mismatch: imported data may not be compatible with current achievement configuration');
1237
+ }
1238
+ // Validate metrics structure
1239
+ if (data.metrics && typeof data.metrics === 'object') {
1240
+ for (const [key, value] of Object.entries(data.metrics)) {
1241
+ if (!Array.isArray(value)) {
1242
+ errors.push(`Invalid metric format for "${key}": expected array, got ${typeof value}`);
1243
+ }
1244
+ }
1245
+ }
1246
+ // Validate achievement IDs are strings
1247
+ if (Array.isArray(data.unlockedAchievements)) {
1248
+ const invalidIds = data.unlockedAchievements.filter((id) => typeof id !== 'string');
1249
+ if (invalidIds.length > 0) {
1250
+ errors.push('All achievement IDs must be strings');
1251
+ }
1252
+ }
1253
+ return errors;
1254
+ }
1255
+ /**
1256
+ * Merges two metrics objects, keeping higher values for overlapping keys
1257
+ */
1258
+ function mergeMetrics(current, imported) {
1259
+ const merged = Object.assign({}, current);
1260
+ for (const [key, importedValues] of Object.entries(imported)) {
1261
+ if (!merged[key]) {
1262
+ // New metric, add it
1263
+ merged[key] = importedValues;
1264
+ }
1265
+ else {
1266
+ // Existing metric, merge values
1267
+ merged[key] = mergeMetricValues(merged[key], importedValues);
1268
+ }
1269
+ }
1270
+ return merged;
1271
+ }
1272
+ /**
1273
+ * Merges two metric value arrays, keeping higher numeric values
1274
+ */
1275
+ function mergeMetricValues(current, imported) {
1276
+ // For simplicity, we'll use the imported values if they're "higher"
1277
+ // This works for numeric values; for other types, we prefer imported
1278
+ const currentValue = current[0];
1279
+ const importedValue = imported[0];
1280
+ // If both are numbers, keep the higher one
1281
+ if (typeof currentValue === 'number' && typeof importedValue === 'number') {
1282
+ return currentValue >= importedValue ? current : imported;
1283
+ }
1284
+ // For non-numeric values, prefer imported (assume it's newer)
1285
+ return imported;
1286
+ }
1287
+ /**
1288
+ * Preserves existing metrics, only adding new ones from imported data
1289
+ */
1290
+ function preserveMetrics(current, imported) {
1291
+ const preserved = Object.assign({}, current);
1292
+ for (const [key, value] of Object.entries(imported)) {
1293
+ if (!preserved[key]) {
1294
+ // Only add if it doesn't exist
1295
+ preserved[key] = value;
1296
+ }
1297
+ // If it exists, keep current value (preserve strategy)
1298
+ }
1299
+ return preserved;
1300
+ }
1301
+
352
1302
  const AchievementContext = createContext(undefined);
353
- const AchievementProvider = ({ achievements: achievementsConfig, storage = StorageType.Local, children, icons = {}, }) => {
1303
+ const AchievementProvider = ({ achievements: achievementsConfig, storage = StorageType.Local, children, icons = {}, onError, restApiConfig, }) => {
354
1304
  // Normalize the configuration to the complex format
355
1305
  const achievements = normalizeAchievements(achievementsConfig);
356
1306
  const [achievementState, setAchievementState] = useState({
@@ -366,19 +1316,41 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = Stora
366
1316
  const [currentAchievement, setCurrentAchievement] = useState(null);
367
1317
  if (!storageRef.current) {
368
1318
  if (typeof storage === 'string') {
369
- if (storage === StorageType.Local) {
370
- storageRef.current = new LocalStorage('achievements');
1319
+ // StorageType enum
1320
+ switch (storage) {
1321
+ case StorageType.Local:
1322
+ storageRef.current = new LocalStorage('achievements');
1323
+ break;
1324
+ case StorageType.Memory:
1325
+ storageRef.current = new MemoryStorage();
1326
+ break;
1327
+ case StorageType.IndexedDB:
1328
+ // Wrap async storage with adapter
1329
+ const indexedDB = new IndexedDBStorage('react-achievements');
1330
+ storageRef.current = new AsyncStorageAdapter(indexedDB, { onError });
1331
+ break;
1332
+ case StorageType.RestAPI:
1333
+ if (!restApiConfig) {
1334
+ throw new ConfigurationError('restApiConfig is required when using StorageType.RestAPI');
1335
+ }
1336
+ // Wrap async storage with adapter
1337
+ const restApi = new RestApiStorage(restApiConfig);
1338
+ storageRef.current = new AsyncStorageAdapter(restApi, { onError });
1339
+ break;
1340
+ default:
1341
+ throw new ConfigurationError(`Unsupported storage type: ${storage}`);
371
1342
  }
372
- else if (storage === StorageType.Memory) {
373
- storageRef.current = new MemoryStorage();
1343
+ }
1344
+ else {
1345
+ // Custom storage instance
1346
+ // Check if it's async storage and wrap with adapter
1347
+ if (isAsyncStorage(storage)) {
1348
+ storageRef.current = new AsyncStorageAdapter(storage, { onError });
374
1349
  }
375
1350
  else {
376
- throw new Error(`Unsupported storage type: ${storage}`);
1351
+ storageRef.current = storage;
377
1352
  }
378
1353
  }
379
- else {
380
- storageRef.current = storage;
381
- }
382
1354
  }
383
1355
  const storageImpl = storageRef.current;
384
1356
  const getNotifiedAchievementsKey = () => {
@@ -520,7 +1492,22 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = Stora
520
1492
  });
521
1493
  setMetrics(updatedMetrics);
522
1494
  const storageMetrics = Object.entries(updatedMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(value) ? value : [value] })), {});
523
- storageImpl.setMetrics(storageMetrics);
1495
+ try {
1496
+ storageImpl.setMetrics(storageMetrics);
1497
+ }
1498
+ catch (error) {
1499
+ if (error instanceof AchievementError) {
1500
+ if (onError) {
1501
+ onError(error);
1502
+ }
1503
+ else {
1504
+ console.error('Achievement storage error:', error.message, error.remedy);
1505
+ }
1506
+ }
1507
+ else {
1508
+ console.error('Unexpected error saving metrics:', error);
1509
+ }
1510
+ }
524
1511
  };
525
1512
  const reset = () => {
526
1513
  var _a, _b;
@@ -547,11 +1534,41 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = Stora
547
1534
  unlocked: achievementState.unlocked,
548
1535
  };
549
1536
  };
1537
+ const exportData = () => {
1538
+ const state = getState();
1539
+ const configHash = createConfigHash(achievementsConfig);
1540
+ return exportAchievementData(state.metrics, state.unlocked, configHash);
1541
+ };
1542
+ const importData = (jsonString, options) => {
1543
+ const state = getState();
1544
+ const configHash = createConfigHash(achievementsConfig);
1545
+ const result = importAchievementData(jsonString, state.metrics, state.unlocked, Object.assign(Object.assign({}, options), { expectedConfigHash: configHash }));
1546
+ if (result.success && 'mergedMetrics' in result && 'mergedUnlocked' in result) {
1547
+ // Apply the imported data
1548
+ const mergedResult = result;
1549
+ // Update metrics state
1550
+ const metricsFromArrayFormat = Object.entries(mergedResult.mergedMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(value) ? value[0] : value })), {});
1551
+ setMetrics(metricsFromArrayFormat);
1552
+ // Update unlocked achievements state
1553
+ setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: mergedResult.mergedUnlocked })));
1554
+ // Persist to storage
1555
+ storageImpl.setMetrics(mergedResult.mergedMetrics);
1556
+ storageImpl.setUnlockedAchievements(mergedResult.mergedUnlocked);
1557
+ // Update seen achievements to prevent duplicate notifications
1558
+ mergedResult.mergedUnlocked.forEach(id => {
1559
+ seenAchievementsRef.current.add(id);
1560
+ });
1561
+ saveNotifiedAchievements(seenAchievementsRef.current);
1562
+ }
1563
+ return result;
1564
+ };
550
1565
  return (React.createElement(AchievementContext.Provider, { value: {
551
1566
  update,
552
1567
  achievements: achievementState,
553
1568
  reset,
554
1569
  getState,
1570
+ exportData,
1571
+ importData,
555
1572
  } },
556
1573
  children,
557
1574
  React.createElement(ToastContainer, { position: "top-right", autoClose: 5000, hideProgressBar: false, newestOnTop: true, closeOnClick: true, rtl: false, pauseOnFocusLoss: true, draggable: true, pauseOnHover: true, theme: "light" }),
@@ -571,7 +1588,7 @@ const useAchievements = () => {
571
1588
  * Provides an easier API for common use cases while maintaining access to advanced features.
572
1589
  */
573
1590
  const useSimpleAchievements = () => {
574
- const { update, achievements, reset, getState } = useAchievements();
1591
+ const { update, achievements, reset, getState, exportData, importData } = useAchievements();
575
1592
  return {
576
1593
  /**
577
1594
  * Track a metric value for achievements
@@ -616,6 +1633,18 @@ const useSimpleAchievements = () => {
616
1633
  * Get current state (advanced usage)
617
1634
  */
618
1635
  getState,
1636
+ /**
1637
+ * Export achievement data to JSON string
1638
+ * @returns JSON string containing all achievement data
1639
+ */
1640
+ exportData,
1641
+ /**
1642
+ * Import achievement data from JSON string
1643
+ * @param jsonString - JSON string containing exported achievement data
1644
+ * @param options - Import options (merge strategy, validation)
1645
+ * @returns Import result with success status and any errors
1646
+ */
1647
+ importData,
619
1648
  };
620
1649
  };
621
1650
 
@@ -982,5 +2011,5 @@ class AchievementBuilder {
982
2011
  }
983
2012
  }
984
2013
 
985
- export { AchievementBuilder, AchievementContext, AchievementProvider, BadgesButton, BadgesModal, ConfettiWrapper, LocalStorage, MemoryStorage, StorageType, defaultAchievementIcons, defaultStyles, isSimpleConfig, normalizeAchievements, useAchievements, useSimpleAchievements };
2014
+ export { AchievementBuilder, AchievementContext, AchievementError, AchievementProvider, AsyncStorageAdapter, BadgesButton, BadgesModal, ConfettiWrapper, ConfigurationError, ImportValidationError, IndexedDBStorage, LocalStorage, MemoryStorage, OfflineQueueStorage, RestApiStorage, StorageError, StorageQuotaError, StorageType, SyncError, createConfigHash, defaultAchievementIcons, defaultStyles, exportAchievementData, importAchievementData, isAchievementError, isAsyncStorage, isRecoverableError, isSimpleConfig, normalizeAchievements, useAchievements, useSimpleAchievements };
986
2015
  //# sourceMappingURL=index.js.map