react-achievements 3.6.5 → 3.8.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 DELETED
@@ -1,2747 +0,0 @@
1
- import React, { useState, useEffect, createContext, useRef, useContext } from 'react';
2
- import Modal from 'react-modal';
3
- import Confetti from 'react-confetti';
4
-
5
- const isDate = (value) => {
6
- return value instanceof Date;
7
- };
8
- // Type guard to detect async storage
9
- function isAsyncStorage(storage) {
10
- // Check if methods return Promises
11
- const testResult = storage.getMetrics();
12
- return testResult && typeof testResult.then === 'function';
13
- }
14
- var StorageType;
15
- (function (StorageType) {
16
- StorageType["Local"] = "local";
17
- StorageType["Memory"] = "memory";
18
- StorageType["IndexedDB"] = "indexeddb";
19
- StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
20
- })(StorageType || (StorageType = {}));
21
-
22
- /**
23
- * Base error class for all achievement-related errors
24
- */
25
- class AchievementError extends Error {
26
- constructor(message, code, recoverable, remedy) {
27
- super(message);
28
- this.code = code;
29
- this.recoverable = recoverable;
30
- this.remedy = remedy;
31
- this.name = 'AchievementError';
32
- // Maintains proper stack trace for where our error was thrown (only available on V8)
33
- if (Error.captureStackTrace) {
34
- Error.captureStackTrace(this, AchievementError);
35
- }
36
- }
37
- }
38
- /**
39
- * Error thrown when browser storage quota is exceeded
40
- */
41
- class StorageQuotaError extends AchievementError {
42
- constructor(bytesNeeded) {
43
- 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.');
44
- this.bytesNeeded = bytesNeeded;
45
- this.name = 'StorageQuotaError';
46
- }
47
- }
48
- /**
49
- * Error thrown when imported data fails validation
50
- */
51
- class ImportValidationError extends AchievementError {
52
- constructor(validationErrors) {
53
- 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.');
54
- this.validationErrors = validationErrors;
55
- this.name = 'ImportValidationError';
56
- }
57
- }
58
- /**
59
- * Error thrown when storage operations fail
60
- */
61
- class StorageError extends AchievementError {
62
- constructor(message, originalError) {
63
- super(message, 'STORAGE_ERROR', true, 'Check browser storage permissions and available space. If using custom storage, verify the implementation is correct.');
64
- this.originalError = originalError;
65
- this.name = 'StorageError';
66
- }
67
- }
68
- /**
69
- * Error thrown when configuration is invalid
70
- */
71
- class ConfigurationError extends AchievementError {
72
- constructor(message) {
73
- super(message, 'CONFIGURATION_ERROR', false, 'Review your achievement configuration and ensure it follows the correct format.');
74
- this.name = 'ConfigurationError';
75
- }
76
- }
77
- /**
78
- * Error thrown when network sync operations fail
79
- */
80
- class SyncError extends AchievementError {
81
- constructor(message, details) {
82
- super(message, 'SYNC_ERROR', true, // recoverable (can retry)
83
- 'Check your network connection and try again. If the problem persists, achievements will sync when connection is restored.');
84
- this.name = 'SyncError';
85
- this.statusCode = details === null || details === void 0 ? void 0 : details.statusCode;
86
- this.timeout = details === null || details === void 0 ? void 0 : details.timeout;
87
- }
88
- }
89
- /**
90
- * Type guard to check if an error is an AchievementError
91
- */
92
- function isAchievementError(error) {
93
- return error instanceof AchievementError;
94
- }
95
- /**
96
- * Type guard to check if an error is recoverable
97
- */
98
- function isRecoverableError(error) {
99
- return isAchievementError(error) && error.recoverable;
100
- }
101
-
102
- class LocalStorage {
103
- constructor(storageKey) {
104
- this.storageKey = storageKey;
105
- }
106
- serializeValue(value) {
107
- if (isDate(value)) {
108
- return { __type: 'Date', value: value.toISOString() };
109
- }
110
- return value;
111
- }
112
- deserializeValue(value) {
113
- if (value && typeof value === 'object' && value.__type === 'Date') {
114
- return new Date(value.value);
115
- }
116
- return value;
117
- }
118
- serializeMetrics(metrics) {
119
- const serialized = {};
120
- for (const [key, values] of Object.entries(metrics)) {
121
- serialized[key] = values.map(this.serializeValue);
122
- }
123
- return serialized;
124
- }
125
- deserializeMetrics(metrics) {
126
- if (!metrics)
127
- return {};
128
- const deserialized = {};
129
- for (const [key, values] of Object.entries(metrics)) {
130
- deserialized[key] = values.map(this.deserializeValue);
131
- }
132
- return deserialized;
133
- }
134
- getStorageData() {
135
- const data = localStorage.getItem(this.storageKey);
136
- if (!data)
137
- return { metrics: {}, unlockedAchievements: [] };
138
- try {
139
- const parsed = JSON.parse(data);
140
- return {
141
- metrics: this.deserializeMetrics(parsed.metrics || {}),
142
- unlockedAchievements: parsed.unlockedAchievements || []
143
- };
144
- }
145
- catch (_a) {
146
- return { metrics: {}, unlockedAchievements: [] };
147
- }
148
- }
149
- setStorageData(data) {
150
- try {
151
- const serialized = {
152
- metrics: this.serializeMetrics(data.metrics),
153
- unlockedAchievements: data.unlockedAchievements
154
- };
155
- const jsonString = JSON.stringify(serialized);
156
- localStorage.setItem(this.storageKey, jsonString);
157
- }
158
- catch (error) {
159
- // Throw proper error instead of silently failing
160
- if (error instanceof DOMException &&
161
- (error.name === 'QuotaExceededError' ||
162
- error.name === 'NS_ERROR_DOM_QUOTA_REACHED')) {
163
- const serialized = {
164
- metrics: this.serializeMetrics(data.metrics),
165
- unlockedAchievements: data.unlockedAchievements
166
- };
167
- const bytesNeeded = JSON.stringify(serialized).length;
168
- throw new StorageQuotaError(bytesNeeded);
169
- }
170
- if (error instanceof Error) {
171
- if (error.message && error.message.includes('QuotaExceeded')) {
172
- const serialized = {
173
- metrics: this.serializeMetrics(data.metrics),
174
- unlockedAchievements: data.unlockedAchievements
175
- };
176
- const bytesNeeded = JSON.stringify(serialized).length;
177
- throw new StorageQuotaError(bytesNeeded);
178
- }
179
- throw new StorageError(`Failed to save achievement data: ${error.message}`, error);
180
- }
181
- throw new StorageError('Failed to save achievement data');
182
- }
183
- }
184
- getMetrics() {
185
- return this.getStorageData().metrics;
186
- }
187
- setMetrics(metrics) {
188
- const data = this.getStorageData();
189
- this.setStorageData(Object.assign(Object.assign({}, data), { metrics }));
190
- }
191
- getUnlockedAchievements() {
192
- return this.getStorageData().unlockedAchievements;
193
- }
194
- setUnlockedAchievements(achievements) {
195
- const data = this.getStorageData();
196
- this.setStorageData(Object.assign(Object.assign({}, data), { unlockedAchievements: achievements }));
197
- }
198
- clear() {
199
- localStorage.removeItem(this.storageKey);
200
- }
201
- }
202
-
203
- class MemoryStorage {
204
- constructor() {
205
- this.metrics = {};
206
- this.unlockedAchievements = [];
207
- }
208
- getMetrics() {
209
- return this.metrics;
210
- }
211
- setMetrics(metrics) {
212
- this.metrics = metrics;
213
- }
214
- getUnlockedAchievements() {
215
- return this.unlockedAchievements;
216
- }
217
- setUnlockedAchievements(achievements) {
218
- this.unlockedAchievements = achievements;
219
- }
220
- clear() {
221
- this.metrics = {};
222
- this.unlockedAchievements = [];
223
- }
224
- }
225
-
226
- /******************************************************************************
227
- Copyright (c) Microsoft Corporation.
228
-
229
- Permission to use, copy, modify, and/or distribute this software for any
230
- purpose with or without fee is hereby granted.
231
-
232
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
233
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
234
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
235
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
236
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
237
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
238
- PERFORMANCE OF THIS SOFTWARE.
239
- ***************************************************************************** */
240
- /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
241
-
242
-
243
- function __awaiter(thisArg, _arguments, P, generator) {
244
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
245
- return new (P || (P = Promise))(function (resolve, reject) {
246
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
247
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
248
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
249
- step((generator = generator.apply(thisArg, _arguments || [])).next());
250
- });
251
- }
252
-
253
- typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
254
- var e = new Error(message);
255
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
256
- };
257
-
258
- class AsyncStorageAdapter {
259
- constructor(asyncStorage, options) {
260
- this.pendingWrites = [];
261
- this.asyncStorage = asyncStorage;
262
- this.onError = options === null || options === void 0 ? void 0 : options.onError;
263
- this.cache = {
264
- metrics: {},
265
- unlocked: [],
266
- loaded: false
267
- };
268
- // Eagerly load data from async storage (non-blocking)
269
- this.initializeCache();
270
- }
271
- /**
272
- * Initialize cache by loading from async storage
273
- * This happens in the background during construction
274
- */
275
- initializeCache() {
276
- return __awaiter(this, void 0, void 0, function* () {
277
- try {
278
- const [metrics, unlocked] = yield Promise.all([
279
- this.asyncStorage.getMetrics(),
280
- this.asyncStorage.getUnlockedAchievements()
281
- ]);
282
- this.cache.metrics = metrics;
283
- this.cache.unlocked = unlocked;
284
- this.cache.loaded = true;
285
- }
286
- catch (error) {
287
- // Handle initialization errors
288
- console.error('Failed to initialize async storage:', error);
289
- if (this.onError) {
290
- const storageError = error instanceof AchievementError
291
- ? error
292
- : new StorageError('Failed to initialize storage', error);
293
- this.onError(storageError);
294
- }
295
- // Set to empty state on error
296
- this.cache.loaded = true; // Mark as loaded even on error to prevent blocking
297
- }
298
- });
299
- }
300
- /**
301
- * Wait for cache to be loaded (used internally)
302
- * Returns immediately if already loaded, otherwise waits
303
- */
304
- ensureCacheLoaded() {
305
- return __awaiter(this, void 0, void 0, function* () {
306
- while (!this.cache.loaded) {
307
- yield new Promise(resolve => setTimeout(resolve, 10));
308
- }
309
- });
310
- }
311
- /**
312
- * SYNC READ: Returns cached metrics immediately
313
- * Cache is loaded eagerly during construction
314
- */
315
- getMetrics() {
316
- return this.cache.metrics;
317
- }
318
- /**
319
- * SYNC WRITE: Updates cache immediately, writes to storage in background
320
- * Uses optimistic updates - assumes write will succeed
321
- */
322
- setMetrics(metrics) {
323
- // Update cache immediately (optimistic update)
324
- this.cache.metrics = metrics;
325
- // Write to async storage in background
326
- const writePromise = this.asyncStorage.setMetrics(metrics).catch(error => {
327
- console.error('Failed to write metrics to async storage:', error);
328
- if (this.onError) {
329
- const storageError = error instanceof AchievementError
330
- ? error
331
- : new StorageError('Failed to write metrics', error);
332
- this.onError(storageError);
333
- }
334
- });
335
- // Track pending write for cleanup/testing
336
- this.pendingWrites.push(writePromise);
337
- }
338
- /**
339
- * SYNC READ: Returns cached unlocked achievements immediately
340
- */
341
- getUnlockedAchievements() {
342
- return this.cache.unlocked;
343
- }
344
- /**
345
- * SYNC WRITE: Updates cache immediately, writes to storage in background
346
- */
347
- setUnlockedAchievements(achievements) {
348
- // Update cache immediately (optimistic update)
349
- this.cache.unlocked = achievements;
350
- // Write to async storage in background
351
- const writePromise = this.asyncStorage.setUnlockedAchievements(achievements).catch(error => {
352
- console.error('Failed to write unlocked achievements to async storage:', error);
353
- if (this.onError) {
354
- const storageError = error instanceof AchievementError
355
- ? error
356
- : new StorageError('Failed to write achievements', error);
357
- this.onError(storageError);
358
- }
359
- });
360
- // Track pending write
361
- this.pendingWrites.push(writePromise);
362
- }
363
- /**
364
- * SYNC CLEAR: Clears cache immediately, clears storage in background
365
- */
366
- clear() {
367
- // Clear cache immediately
368
- this.cache.metrics = {};
369
- this.cache.unlocked = [];
370
- // Clear async storage in background
371
- const clearPromise = this.asyncStorage.clear().catch(error => {
372
- console.error('Failed to clear async storage:', error);
373
- if (this.onError) {
374
- const storageError = error instanceof AchievementError
375
- ? error
376
- : new StorageError('Failed to clear storage', error);
377
- this.onError(storageError);
378
- }
379
- });
380
- // Track pending write
381
- this.pendingWrites.push(clearPromise);
382
- }
383
- /**
384
- * Wait for all pending writes to complete (useful for testing/cleanup)
385
- * NOT part of AchievementStorage interface - utility method
386
- */
387
- flush() {
388
- return __awaiter(this, void 0, void 0, function* () {
389
- yield Promise.all(this.pendingWrites);
390
- this.pendingWrites = [];
391
- });
392
- }
393
- }
394
-
395
- class IndexedDBStorage {
396
- constructor(dbName = 'react-achievements') {
397
- this.storeName = 'achievements';
398
- this.db = null;
399
- this.dbName = dbName;
400
- this.initPromise = this.initDB();
401
- }
402
- /**
403
- * Initialize IndexedDB database and object store
404
- */
405
- initDB() {
406
- return __awaiter(this, void 0, void 0, function* () {
407
- return new Promise((resolve, reject) => {
408
- const request = indexedDB.open(this.dbName, 1);
409
- request.onerror = () => {
410
- reject(new StorageError('Failed to open IndexedDB'));
411
- };
412
- request.onsuccess = () => {
413
- this.db = request.result;
414
- resolve();
415
- };
416
- request.onupgradeneeded = (event) => {
417
- const db = event.target.result;
418
- // Create object store if it doesn't exist
419
- if (!db.objectStoreNames.contains(this.storeName)) {
420
- db.createObjectStore(this.storeName);
421
- }
422
- };
423
- });
424
- });
425
- }
426
- /**
427
- * Generic get operation from IndexedDB
428
- */
429
- get(key) {
430
- return __awaiter(this, void 0, void 0, function* () {
431
- yield this.initPromise;
432
- if (!this.db)
433
- throw new StorageError('Database not initialized');
434
- return new Promise((resolve, reject) => {
435
- const transaction = this.db.transaction([this.storeName], 'readonly');
436
- const store = transaction.objectStore(this.storeName);
437
- const request = store.get(key);
438
- request.onsuccess = () => {
439
- resolve(request.result || null);
440
- };
441
- request.onerror = () => {
442
- reject(new StorageError(`Failed to read from IndexedDB: ${key}`));
443
- };
444
- });
445
- });
446
- }
447
- /**
448
- * Generic set operation to IndexedDB
449
- */
450
- set(key, value) {
451
- return __awaiter(this, void 0, void 0, function* () {
452
- yield this.initPromise;
453
- if (!this.db)
454
- throw new StorageError('Database not initialized');
455
- return new Promise((resolve, reject) => {
456
- const transaction = this.db.transaction([this.storeName], 'readwrite');
457
- const store = transaction.objectStore(this.storeName);
458
- const request = store.put(value, key);
459
- request.onsuccess = () => {
460
- resolve();
461
- };
462
- request.onerror = () => {
463
- reject(new StorageError(`Failed to write to IndexedDB: ${key}`));
464
- };
465
- });
466
- });
467
- }
468
- /**
469
- * Delete operation from IndexedDB
470
- */
471
- delete(key) {
472
- return __awaiter(this, void 0, void 0, function* () {
473
- yield this.initPromise;
474
- if (!this.db)
475
- throw new StorageError('Database not initialized');
476
- return new Promise((resolve, reject) => {
477
- const transaction = this.db.transaction([this.storeName], 'readwrite');
478
- const store = transaction.objectStore(this.storeName);
479
- const request = store.delete(key);
480
- request.onsuccess = () => {
481
- resolve();
482
- };
483
- request.onerror = () => {
484
- reject(new StorageError(`Failed to delete from IndexedDB: ${key}`));
485
- };
486
- });
487
- });
488
- }
489
- getMetrics() {
490
- return __awaiter(this, void 0, void 0, function* () {
491
- const metrics = yield this.get('metrics');
492
- return metrics || {};
493
- });
494
- }
495
- setMetrics(metrics) {
496
- return __awaiter(this, void 0, void 0, function* () {
497
- yield this.set('metrics', metrics);
498
- });
499
- }
500
- getUnlockedAchievements() {
501
- return __awaiter(this, void 0, void 0, function* () {
502
- const unlocked = yield this.get('unlocked');
503
- return unlocked || [];
504
- });
505
- }
506
- setUnlockedAchievements(achievements) {
507
- return __awaiter(this, void 0, void 0, function* () {
508
- yield this.set('unlocked', achievements);
509
- });
510
- }
511
- clear() {
512
- return __awaiter(this, void 0, void 0, function* () {
513
- yield Promise.all([
514
- this.delete('metrics'),
515
- this.delete('unlocked')
516
- ]);
517
- });
518
- }
519
- }
520
-
521
- class RestApiStorage {
522
- constructor(config) {
523
- this.config = Object.assign({ timeout: 10000, headers: {} }, config);
524
- }
525
- /**
526
- * Generic fetch wrapper with timeout and error handling
527
- */
528
- fetchWithTimeout(url, options) {
529
- return __awaiter(this, void 0, void 0, function* () {
530
- const controller = new AbortController();
531
- const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
532
- try {
533
- 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 }));
534
- clearTimeout(timeoutId);
535
- if (!response.ok) {
536
- throw new SyncError(`HTTP ${response.status}: ${response.statusText}`, { statusCode: response.status });
537
- }
538
- return response;
539
- }
540
- catch (error) {
541
- clearTimeout(timeoutId);
542
- if (error instanceof Error && error.name === 'AbortError') {
543
- throw new SyncError('Request timeout', { timeout: this.config.timeout });
544
- }
545
- throw error;
546
- }
547
- });
548
- }
549
- getMetrics() {
550
- return __awaiter(this, void 0, void 0, function* () {
551
- var _a;
552
- try {
553
- const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
554
- const response = yield this.fetchWithTimeout(url, { method: 'GET' });
555
- const data = yield response.json();
556
- return data.metrics || {};
557
- }
558
- catch (error) {
559
- // Re-throw SyncError and other AchievementErrors (but not StorageError)
560
- // Multiple checks for Jest compatibility
561
- const err = error;
562
- 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') {
563
- throw error;
564
- }
565
- // Also check instanceof for normal cases
566
- if (error instanceof AchievementError && !(error instanceof StorageError)) {
567
- throw error;
568
- }
569
- throw new StorageError('Failed to fetch metrics from API', error);
570
- }
571
- });
572
- }
573
- setMetrics(metrics) {
574
- return __awaiter(this, void 0, void 0, function* () {
575
- var _a;
576
- try {
577
- const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
578
- yield this.fetchWithTimeout(url, {
579
- method: 'PUT',
580
- body: JSON.stringify({ metrics })
581
- });
582
- }
583
- catch (error) {
584
- const err = error;
585
- 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')
586
- throw error;
587
- if (error instanceof AchievementError && !(error instanceof StorageError))
588
- throw error;
589
- throw new StorageError('Failed to save metrics to API', error);
590
- }
591
- });
592
- }
593
- getUnlockedAchievements() {
594
- return __awaiter(this, void 0, void 0, function* () {
595
- var _a;
596
- try {
597
- const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
598
- const response = yield this.fetchWithTimeout(url, { method: 'GET' });
599
- const data = yield response.json();
600
- return data.unlocked || [];
601
- }
602
- catch (error) {
603
- const err = error;
604
- 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')
605
- throw error;
606
- if (error instanceof AchievementError && !(error instanceof StorageError))
607
- throw error;
608
- throw new StorageError('Failed to fetch unlocked achievements from API', error);
609
- }
610
- });
611
- }
612
- setUnlockedAchievements(achievements) {
613
- return __awaiter(this, void 0, void 0, function* () {
614
- var _a;
615
- try {
616
- const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
617
- yield this.fetchWithTimeout(url, {
618
- method: 'PUT',
619
- body: JSON.stringify({ unlocked: achievements })
620
- });
621
- }
622
- catch (error) {
623
- const err = error;
624
- 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')
625
- throw error;
626
- if (error instanceof AchievementError && !(error instanceof StorageError))
627
- throw error;
628
- throw new StorageError('Failed to save unlocked achievements to API', error);
629
- }
630
- });
631
- }
632
- clear() {
633
- return __awaiter(this, void 0, void 0, function* () {
634
- var _a;
635
- try {
636
- const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements`;
637
- yield this.fetchWithTimeout(url, { method: 'DELETE' });
638
- }
639
- catch (error) {
640
- const err = error;
641
- 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')
642
- throw error;
643
- if (error instanceof AchievementError && !(error instanceof StorageError))
644
- throw error;
645
- throw new StorageError('Failed to clear achievements via API', error);
646
- }
647
- });
648
- }
649
- }
650
-
651
- class OfflineQueueStorage {
652
- constructor(innerStorage) {
653
- this.queue = [];
654
- this.isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
655
- this.isSyncing = false;
656
- this.queueStorageKey = 'achievements_offline_queue';
657
- this.handleOnline = () => {
658
- this.isOnline = true;
659
- console.log('[OfflineQueue] Back online, processing queue...');
660
- this.processQueue();
661
- };
662
- this.handleOffline = () => {
663
- this.isOnline = false;
664
- console.log('[OfflineQueue] Offline mode activated');
665
- };
666
- this.innerStorage = innerStorage;
667
- // Load queued operations from localStorage
668
- this.loadQueue();
669
- // Listen for online/offline events (only in browser environment)
670
- if (typeof window !== 'undefined') {
671
- window.addEventListener('online', this.handleOnline);
672
- window.addEventListener('offline', this.handleOffline);
673
- }
674
- // Process queue if already online
675
- if (this.isOnline) {
676
- this.processQueue();
677
- }
678
- }
679
- loadQueue() {
680
- try {
681
- if (typeof localStorage !== 'undefined') {
682
- const queueData = localStorage.getItem(this.queueStorageKey);
683
- if (queueData) {
684
- this.queue = JSON.parse(queueData);
685
- }
686
- }
687
- }
688
- catch (error) {
689
- console.error('Failed to load offline queue:', error);
690
- this.queue = [];
691
- }
692
- }
693
- saveQueue() {
694
- try {
695
- if (typeof localStorage !== 'undefined') {
696
- localStorage.setItem(this.queueStorageKey, JSON.stringify(this.queue));
697
- }
698
- }
699
- catch (error) {
700
- console.error('Failed to save offline queue:', error);
701
- }
702
- }
703
- processQueue() {
704
- return __awaiter(this, void 0, void 0, function* () {
705
- if (this.isSyncing || this.queue.length === 0 || !this.isOnline) {
706
- return;
707
- }
708
- this.isSyncing = true;
709
- try {
710
- // Process operations in order
711
- while (this.queue.length > 0 && this.isOnline) {
712
- const operation = this.queue[0];
713
- try {
714
- switch (operation.type) {
715
- case 'setMetrics':
716
- yield this.innerStorage.setMetrics(operation.data);
717
- break;
718
- case 'setUnlockedAchievements':
719
- yield this.innerStorage.setUnlockedAchievements(operation.data);
720
- break;
721
- case 'clear':
722
- yield this.innerStorage.clear();
723
- break;
724
- }
725
- // Operation succeeded, remove from queue
726
- this.queue.shift();
727
- this.saveQueue();
728
- }
729
- catch (error) {
730
- console.error('Failed to sync queued operation:', error);
731
- // Stop processing on error, will retry later
732
- break;
733
- }
734
- }
735
- }
736
- finally {
737
- this.isSyncing = false;
738
- }
739
- });
740
- }
741
- queueOperation(type, data) {
742
- const operation = {
743
- id: `${Date.now()}_${Math.random()}`,
744
- type,
745
- data,
746
- timestamp: Date.now()
747
- };
748
- this.queue.push(operation);
749
- this.saveQueue();
750
- // Try to process queue if online
751
- if (this.isOnline) {
752
- this.processQueue();
753
- }
754
- }
755
- getMetrics() {
756
- return __awaiter(this, void 0, void 0, function* () {
757
- // Reads always try to hit the server first
758
- try {
759
- return yield this.innerStorage.getMetrics();
760
- }
761
- catch (error) {
762
- if (!this.isOnline) {
763
- throw new StorageError('Cannot read metrics while offline');
764
- }
765
- throw error;
766
- }
767
- });
768
- }
769
- setMetrics(metrics) {
770
- return __awaiter(this, void 0, void 0, function* () {
771
- if (this.isOnline) {
772
- try {
773
- yield this.innerStorage.setMetrics(metrics);
774
- return;
775
- }
776
- catch (error) {
777
- // Failed while online, queue it
778
- console.warn('Failed to set metrics, queuing for later:', error);
779
- }
780
- }
781
- // Queue operation if offline or if online operation failed
782
- this.queueOperation('setMetrics', metrics);
783
- });
784
- }
785
- getUnlockedAchievements() {
786
- return __awaiter(this, void 0, void 0, function* () {
787
- // Reads always try to hit the server first
788
- try {
789
- return yield this.innerStorage.getUnlockedAchievements();
790
- }
791
- catch (error) {
792
- if (!this.isOnline) {
793
- throw new StorageError('Cannot read achievements while offline');
794
- }
795
- throw error;
796
- }
797
- });
798
- }
799
- setUnlockedAchievements(achievements) {
800
- return __awaiter(this, void 0, void 0, function* () {
801
- if (this.isOnline) {
802
- try {
803
- yield this.innerStorage.setUnlockedAchievements(achievements);
804
- return;
805
- }
806
- catch (error) {
807
- // Failed while online, queue it
808
- console.warn('Failed to set unlocked achievements, queuing for later:', error);
809
- }
810
- }
811
- // Queue operation if offline or if online operation failed
812
- this.queueOperation('setUnlockedAchievements', achievements);
813
- });
814
- }
815
- clear() {
816
- return __awaiter(this, void 0, void 0, function* () {
817
- if (this.isOnline) {
818
- try {
819
- yield this.innerStorage.clear();
820
- // Also clear the queue
821
- this.queue = [];
822
- this.saveQueue();
823
- return;
824
- }
825
- catch (error) {
826
- console.warn('Failed to clear, queuing for later:', error);
827
- }
828
- }
829
- // Queue operation if offline or if online operation failed
830
- this.queueOperation('clear');
831
- });
832
- }
833
- /**
834
- * Manually trigger queue processing (useful for testing)
835
- */
836
- sync() {
837
- return __awaiter(this, void 0, void 0, function* () {
838
- yield this.processQueue();
839
- });
840
- }
841
- /**
842
- * Get current queue status (useful for debugging)
843
- */
844
- getQueueStatus() {
845
- return {
846
- pending: this.queue.length,
847
- operations: [...this.queue]
848
- };
849
- }
850
- /**
851
- * Cleanup listeners (call on unmount)
852
- */
853
- destroy() {
854
- if (typeof window !== 'undefined') {
855
- window.removeEventListener('online', this.handleOnline);
856
- window.removeEventListener('offline', this.handleOffline);
857
- }
858
- }
859
- }
860
-
861
- /**
862
- * Built-in theme presets
863
- */
864
- const builtInThemes = {
865
- /**
866
- * Modern theme - Dark gradients with vibrant accents
867
- * Inspired by contemporary achievement systems (Discord, Steam, Xbox)
868
- */
869
- modern: {
870
- name: 'modern',
871
- notification: {
872
- background: 'linear-gradient(135deg, rgba(30, 30, 50, 0.98) 0%, rgba(50, 50, 70, 0.98) 100%)',
873
- textColor: '#ffffff',
874
- accentColor: '#4CAF50',
875
- borderRadius: '12px',
876
- boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
877
- fontSize: {
878
- header: '12px',
879
- title: '18px',
880
- description: '14px',
881
- },
882
- },
883
- modal: {
884
- overlayColor: 'rgba(0, 0, 0, 0.85)',
885
- background: 'linear-gradient(135deg, #1e1e32 0%, #323246 100%)',
886
- textColor: '#ffffff',
887
- accentColor: '#4CAF50',
888
- borderRadius: '16px',
889
- headerFontSize: '28px',
890
- },
891
- confetti: {
892
- colors: ['#FFD700', '#4CAF50', '#2196F3', '#FF6B6B'],
893
- particleCount: 50,
894
- shapes: ['circle', 'square'],
895
- },
896
- },
897
- /**
898
- * Minimal theme - Clean, light design with subtle accents
899
- * Perfect for professional or minimalist applications
900
- */
901
- minimal: {
902
- name: 'minimal',
903
- notification: {
904
- background: 'rgba(255, 255, 255, 0.98)',
905
- textColor: '#333333',
906
- accentColor: '#4CAF50',
907
- borderRadius: '8px',
908
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
909
- fontSize: {
910
- header: '11px',
911
- title: '16px',
912
- description: '13px',
913
- },
914
- },
915
- modal: {
916
- overlayColor: 'rgba(0, 0, 0, 0.5)',
917
- background: '#ffffff',
918
- textColor: '#333333',
919
- accentColor: '#4CAF50',
920
- borderRadius: '12px',
921
- headerFontSize: '24px',
922
- },
923
- confetti: {
924
- colors: ['#4CAF50', '#2196F3'],
925
- particleCount: 30,
926
- shapes: ['circle'],
927
- },
928
- },
929
- /**
930
- * Gamified theme - Modern gaming aesthetic with sci-fi colors
931
- * Dark navy backgrounds with cyan and orange accents (2024 gaming trend)
932
- * Features square/badge-shaped achievement cards
933
- */
934
- gamified: {
935
- name: 'gamified',
936
- notification: {
937
- background: 'linear-gradient(135deg, rgba(5, 8, 22, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%)',
938
- textColor: '#22d3ee', // Bright cyan
939
- accentColor: '#f97316', // Bright orange
940
- borderRadius: '6px',
941
- boxShadow: '0 8px 32px rgba(34, 211, 238, 0.4), 0 0 20px rgba(249, 115, 22, 0.3)',
942
- fontSize: {
943
- header: '13px',
944
- title: '20px',
945
- description: '15px',
946
- },
947
- },
948
- modal: {
949
- overlayColor: 'rgba(5, 8, 22, 0.85)',
950
- background: 'linear-gradient(135deg, #0f172a 0%, #050816 100%)',
951
- textColor: '#22d3ee', // Bright cyan
952
- accentColor: '#f97316', // Bright orange
953
- borderRadius: '8px',
954
- headerFontSize: '32px',
955
- achievementCardBorderRadius: '8px', // Square badge-like cards
956
- achievementLayout: 'badge', // Use badge/grid layout instead of horizontal list
957
- },
958
- confetti: {
959
- colors: ['#22d3ee', '#f97316', '#a855f7', '#eab308'], // Cyan, orange, purple, yellow
960
- particleCount: 100,
961
- shapes: ['circle', 'square'],
962
- },
963
- },
964
- };
965
- /**
966
- * Retrieve a theme by name (internal use only)
967
- * Only checks built-in themes
968
- *
969
- * @param name - Theme name (built-in only)
970
- * @returns Theme configuration or undefined if not found
971
- * @internal
972
- */
973
- function getTheme(name) {
974
- return builtInThemes[name];
975
- }
976
-
977
- const getPositionStyles = (position) => {
978
- const base = {
979
- position: 'fixed',
980
- margin: '20px',
981
- zIndex: 1000,
982
- };
983
- switch (position) {
984
- case 'top-left':
985
- return Object.assign(Object.assign({}, base), { top: 0, left: 0 });
986
- case 'top-right':
987
- return Object.assign(Object.assign({}, base), { top: 0, right: 0 });
988
- case 'bottom-left':
989
- return Object.assign(Object.assign({}, base), { bottom: 0, left: 0 });
990
- case 'bottom-right':
991
- return Object.assign(Object.assign({}, base), { bottom: 0, right: 0 });
992
- }
993
- };
994
- const BadgesButton = ({ onClick, position = 'bottom-right', placement = 'fixed', styles = {}, unlockedAchievements, theme = 'modern', }) => {
995
- // Get theme configuration for consistent styling
996
- const themeConfig = getTheme(theme) || builtInThemes.modern;
997
- const accentColor = themeConfig.notification.accentColor;
998
- // Different styling for fixed vs inline placement
999
- const baseStyles = placement === 'inline'
1000
- ? Object.assign({
1001
- // Inline mode: looks like a navigation item
1002
- backgroundColor: 'transparent', color: themeConfig.notification.textColor, padding: '12px 16px', border: 'none', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', fontSize: '15px', width: '100%', textAlign: 'left', transition: 'background-color 0.2s ease-in-out' }, styles) : Object.assign(Object.assign({
1003
- // Fixed mode: floating button
1004
- backgroundColor: accentColor, color: 'white', padding: '10px 20px', border: 'none', borderRadius: '20px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '16px', boxShadow: '0 2px 5px rgba(0,0,0,0.2)', transition: 'transform 0.2s ease-in-out' }, getPositionStyles(position)), styles);
1005
- return (React.createElement("button", { onClick: onClick, style: baseStyles, onMouseEnter: (e) => {
1006
- if (placement === 'inline') {
1007
- // Inline mode: subtle background color change
1008
- e.target.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
1009
- }
1010
- else {
1011
- // Fixed mode: scale transformation
1012
- e.target.style.transform = 'scale(1.05)';
1013
- }
1014
- }, onMouseLeave: (e) => {
1015
- if (placement === 'inline') {
1016
- e.target.style.backgroundColor = 'transparent';
1017
- }
1018
- else {
1019
- e.target.style.transform = 'scale(1)';
1020
- }
1021
- }, "data-placement": placement, "data-testid": "badges-button" },
1022
- "\uD83C\uDFC6 Achievements (",
1023
- unlockedAchievements.length,
1024
- ")"));
1025
- };
1026
-
1027
- const defaultAchievementIcons = {
1028
- // Essential fallback icons for system use
1029
- default: '⭐', // Fallback when no icon is provided
1030
- loading: '⏳', // For loading states
1031
- error: '⚠️', // For error states
1032
- success: '✅', // For success states
1033
- // A few common icons for backward compatibility
1034
- trophy: '🏆',
1035
- star: '⭐',
1036
- };
1037
-
1038
- const defaultStyles = {
1039
- badgesButton: {
1040
- backgroundColor: '#4CAF50',
1041
- color: 'white',
1042
- padding: '10px 20px',
1043
- border: 'none',
1044
- borderRadius: '20px',
1045
- cursor: 'pointer',
1046
- display: 'flex',
1047
- alignItems: 'center',
1048
- gap: '8px',
1049
- fontSize: '16px',
1050
- boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
1051
- transition: 'transform 0.2s ease-in-out',
1052
- },
1053
- badgesModal: {
1054
- overlay: {
1055
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
1056
- display: 'flex',
1057
- alignItems: 'center',
1058
- justifyContent: 'center',
1059
- zIndex: 1000,
1060
- },
1061
- content: {
1062
- position: 'relative',
1063
- background: '#fff',
1064
- borderRadius: '8px',
1065
- padding: '20px',
1066
- maxWidth: '500px',
1067
- width: '90%',
1068
- maxHeight: '80vh',
1069
- overflow: 'auto',
1070
- },
1071
- header: {
1072
- display: 'flex',
1073
- justifyContent: 'space-between',
1074
- alignItems: 'center',
1075
- marginBottom: '20px',
1076
- },
1077
- closeButton: {
1078
- background: 'none',
1079
- border: 'none',
1080
- fontSize: '24px',
1081
- cursor: 'pointer',
1082
- padding: '0',
1083
- },
1084
- achievementList: {
1085
- display: 'flex',
1086
- flexDirection: 'column',
1087
- gap: '16px',
1088
- },
1089
- achievementItem: {
1090
- display: 'flex',
1091
- gap: '16px',
1092
- padding: '16px',
1093
- borderRadius: '8px',
1094
- backgroundColor: '#f5f5f5',
1095
- alignItems: 'center',
1096
- },
1097
- achievementTitle: {
1098
- margin: '0',
1099
- fontSize: '18px',
1100
- fontWeight: 'bold',
1101
- },
1102
- achievementDescription: {
1103
- margin: '4px 0 0 0',
1104
- color: '#666',
1105
- },
1106
- achievementIcon: {
1107
- fontSize: '32px',
1108
- display: 'flex',
1109
- alignItems: 'center',
1110
- justifyContent: 'center',
1111
- },
1112
- lockIcon: {
1113
- fontSize: '24px',
1114
- position: 'absolute',
1115
- top: '50%',
1116
- right: '16px',
1117
- transform: 'translateY(-50%)',
1118
- },
1119
- lockedAchievementItem: {
1120
- display: 'flex',
1121
- gap: '16px',
1122
- padding: '16px',
1123
- borderRadius: '8px',
1124
- backgroundColor: '#e0e0e0',
1125
- alignItems: 'center',
1126
- opacity: 0.5,
1127
- },
1128
- },
1129
- };
1130
-
1131
- const BadgesModal = ({ isOpen, onClose, achievements, styles = {}, icons = {}, showAllAchievements = false, showUnlockConditions = false, allAchievements, }) => {
1132
- // Merge custom icons with default icons, with custom icons taking precedence
1133
- const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
1134
- return (React.createElement(Modal, { isOpen: isOpen, onRequestClose: onClose, style: {
1135
- overlay: Object.assign(Object.assign({}, defaultStyles.badgesModal.overlay), styles === null || styles === void 0 ? void 0 : styles.overlay),
1136
- content: Object.assign(Object.assign({}, defaultStyles.badgesModal.content), styles === null || styles === void 0 ? void 0 : styles.content)
1137
- }, contentLabel: "Achievements" },
1138
- React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.header), styles === null || styles === void 0 ? void 0 : styles.header) },
1139
- React.createElement("h2", { style: { margin: 0 } }, "\uD83C\uDFC6 Achievements"),
1140
- React.createElement("button", { onClick: onClose, style: Object.assign(Object.assign({}, defaultStyles.badgesModal.closeButton), styles === null || styles === void 0 ? void 0 : styles.closeButton), "aria-label": "Close" }, "\u00D7")),
1141
- React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementList), styles === null || styles === void 0 ? void 0 : styles.achievementList) }, (() => {
1142
- // Determine which achievements to display
1143
- const achievementsToDisplay = showAllAchievements && allAchievements
1144
- ? allAchievements
1145
- : achievements.map(a => (Object.assign(Object.assign({}, a), { isUnlocked: true })));
1146
- return (React.createElement(React.Fragment, null,
1147
- achievementsToDisplay.map((achievement) => {
1148
- const isLocked = !achievement.isUnlocked;
1149
- return (React.createElement("div", { key: achievement.achievementId, style: Object.assign(Object.assign(Object.assign({}, (isLocked
1150
- ? Object.assign(Object.assign({}, defaultStyles.badgesModal.lockedAchievementItem), styles === null || styles === void 0 ? void 0 : styles.lockedAchievementItem) : defaultStyles.badgesModal.achievementItem)), styles === null || styles === void 0 ? void 0 : styles.achievementItem), { position: 'relative' }) },
1151
- achievement.achievementIconKey && (React.createElement("div", { style: Object.assign(Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementIcon), styles === null || styles === void 0 ? void 0 : styles.achievementIcon), { opacity: isLocked ? 0.4 : 1 }) }, achievement.achievementIconKey in mergedIcons
1152
- ? mergedIcons[achievement.achievementIconKey]
1153
- : mergedIcons.default || '⭐')),
1154
- React.createElement("div", { style: { flex: 1 } },
1155
- React.createElement("h3", { style: Object.assign(Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementTitle), styles === null || styles === void 0 ? void 0 : styles.achievementTitle), { color: isLocked ? '#999' : undefined }) }, achievement.achievementTitle),
1156
- React.createElement("p", { style: Object.assign(Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementDescription), styles === null || styles === void 0 ? void 0 : styles.achievementDescription), { color: isLocked ? '#aaa' : '#666' }) },
1157
- achievement.achievementDescription,
1158
- showUnlockConditions && isLocked && achievement.achievementDescription && (React.createElement("span", { style: {
1159
- display: 'block',
1160
- fontSize: '12px',
1161
- marginTop: '4px',
1162
- fontStyle: 'italic',
1163
- color: '#888'
1164
- } },
1165
- "\uD83D\uDD13 ",
1166
- achievement.achievementDescription)))),
1167
- isLocked && (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.lockIcon), styles === null || styles === void 0 ? void 0 : styles.lockIcon) }, "\uD83D\uDD12"))));
1168
- }),
1169
- achievementsToDisplay.length === 0 && (React.createElement("p", { style: { textAlign: 'center', color: '#666' } }, "No achievements configured."))));
1170
- })())));
1171
- };
1172
-
1173
- /**
1174
- * Hook to track window dimensions
1175
- * Replacement for react-use's useWindowSize
1176
- *
1177
- * @returns Object with width and height properties
1178
- *
1179
- * @example
1180
- * ```tsx
1181
- * const { width, height } = useWindowSize();
1182
- * console.log(`Window size: ${width}x${height}`);
1183
- * ```
1184
- */
1185
- function useWindowSize() {
1186
- const [size, setSize] = useState({
1187
- width: typeof window !== 'undefined' ? window.innerWidth : 0,
1188
- height: typeof window !== 'undefined' ? window.innerHeight : 0,
1189
- });
1190
- useEffect(() => {
1191
- // Handle SSR - window may not be defined
1192
- if (typeof window === 'undefined') {
1193
- return;
1194
- }
1195
- const handleResize = () => {
1196
- setSize({
1197
- width: window.innerWidth,
1198
- height: window.innerHeight,
1199
- });
1200
- };
1201
- // Set initial size
1202
- handleResize();
1203
- // Add event listener
1204
- window.addEventListener('resize', handleResize);
1205
- // Cleanup
1206
- return () => {
1207
- window.removeEventListener('resize', handleResize);
1208
- };
1209
- }, []);
1210
- return size;
1211
- }
1212
-
1213
- const ConfettiWrapper = ({ show }) => {
1214
- const { width, height } = useWindowSize();
1215
- if (!show)
1216
- return null;
1217
- return (React.createElement(Confetti, { width: width, height: height, numberOfPieces: 200, recycle: false, style: {
1218
- position: 'fixed',
1219
- top: 0,
1220
- left: 0,
1221
- zIndex: 1001,
1222
- pointerEvents: 'none',
1223
- } }));
1224
- };
1225
-
1226
- // Type guard to check if config is simple format
1227
- function isSimpleConfig(config) {
1228
- if (!config || typeof config !== 'object')
1229
- return false;
1230
- const firstKey = Object.keys(config)[0];
1231
- if (!firstKey)
1232
- return true; // Empty config is considered simple
1233
- const firstValue = config[firstKey];
1234
- // Check if it's the current complex format (array of AchievementCondition)
1235
- if (Array.isArray(firstValue))
1236
- return false;
1237
- // Check if it's the simple format (object with string keys)
1238
- return typeof firstValue === 'object' && !Array.isArray(firstValue);
1239
- }
1240
- // Generate a unique ID for achievements
1241
- function generateId() {
1242
- return Math.random().toString(36).substr(2, 9);
1243
- }
1244
- // Check if achievement details has a custom condition
1245
- function hasCustomCondition(details) {
1246
- return 'condition' in details && typeof details.condition === 'function';
1247
- }
1248
- // Convert simple config to complex config format
1249
- function normalizeAchievements(config) {
1250
- if (!isSimpleConfig(config)) {
1251
- // Already in complex format, return as-is
1252
- return config;
1253
- }
1254
- const normalized = {};
1255
- Object.entries(config).forEach(([metric, achievements]) => {
1256
- normalized[metric] = Object.entries(achievements).map(([key, achievement]) => {
1257
- if (hasCustomCondition(achievement)) {
1258
- // Custom condition function
1259
- return {
1260
- isConditionMet: (_value, _state) => {
1261
- // Convert internal metrics format (arrays) to simple format for custom conditions
1262
- const simpleMetrics = {};
1263
- Object.entries(_state.metrics).forEach(([key, val]) => {
1264
- simpleMetrics[key] = Array.isArray(val) ? val[0] : val;
1265
- });
1266
- return achievement.condition(simpleMetrics);
1267
- },
1268
- achievementDetails: {
1269
- achievementId: `${metric}_custom_${generateId()}`,
1270
- achievementTitle: achievement.title,
1271
- achievementDescription: achievement.description || '',
1272
- achievementIconKey: achievement.icon || 'default'
1273
- }
1274
- };
1275
- }
1276
- else {
1277
- // Threshold-based achievement
1278
- const threshold = parseFloat(key);
1279
- const isValidThreshold = !isNaN(threshold);
1280
- let conditionMet;
1281
- if (isValidThreshold) {
1282
- // Numeric threshold
1283
- conditionMet = (value) => {
1284
- const numValue = Array.isArray(value) ? value[0] : value;
1285
- return typeof numValue === 'number' && numValue >= threshold;
1286
- };
1287
- }
1288
- else {
1289
- // String or boolean threshold
1290
- conditionMet = (value) => {
1291
- const actualValue = Array.isArray(value) ? value[0] : value;
1292
- // Handle boolean thresholds
1293
- if (key === 'true')
1294
- return actualValue === true;
1295
- if (key === 'false')
1296
- return actualValue === false;
1297
- // Handle string thresholds
1298
- return actualValue === key;
1299
- };
1300
- }
1301
- return {
1302
- isConditionMet: conditionMet,
1303
- achievementDetails: {
1304
- achievementId: `${metric}_${key}`,
1305
- achievementTitle: achievement.title,
1306
- achievementDescription: achievement.description || (isValidThreshold ? `Reach ${threshold} ${metric}` : `Achieve ${key} for ${metric}`),
1307
- achievementIconKey: achievement.icon || 'default'
1308
- }
1309
- };
1310
- }
1311
- });
1312
- });
1313
- return normalized;
1314
- }
1315
-
1316
- /**
1317
- * Exports achievement data to a JSON string
1318
- *
1319
- * @param metrics - Current achievement metrics
1320
- * @param unlocked - Array of unlocked achievement IDs
1321
- * @param configHash - Optional hash of achievement configuration for validation
1322
- * @returns JSON string containing all achievement data
1323
- *
1324
- * @example
1325
- * ```typescript
1326
- * const json = exportAchievementData(_metrics, ['score_100', 'level_5']);
1327
- * // Save json to file or send to server
1328
- * ```
1329
- */
1330
- function exportAchievementData(metrics, unlocked, configHash) {
1331
- const data = Object.assign({ version: '3.3.0', timestamp: Date.now(), metrics, unlockedAchievements: unlocked }, (configHash && { configHash }));
1332
- return JSON.stringify(data, null, 2);
1333
- }
1334
- /**
1335
- * Creates a simple hash of the achievement configuration
1336
- * Used to validate that imported data matches the current configuration
1337
- *
1338
- * @param config - Achievement configuration object
1339
- * @returns Simple hash string
1340
- */
1341
- function createConfigHash(config) {
1342
- // Simple hash based on stringified config
1343
- // In production, you might want to use a more robust hashing algorithm
1344
- const str = JSON.stringify(config);
1345
- let hash = 0;
1346
- for (let i = 0; i < str.length; i++) {
1347
- const char = str.charCodeAt(i);
1348
- hash = ((hash << 5) - hash) + char;
1349
- hash = hash & hash; // Convert to 32bit integer
1350
- }
1351
- return hash.toString(36);
1352
- }
1353
-
1354
- /**
1355
- * Imports achievement data from a JSON string
1356
- *
1357
- * @param jsonString - JSON string containing exported achievement data
1358
- * @param currentMetrics - Current metrics state
1359
- * @param currentUnlocked - Current unlocked achievements
1360
- * @param options - Import options
1361
- * @returns Import result with success status and any errors
1362
- *
1363
- * @example
1364
- * ```typescript
1365
- * const result = importAchievementData(
1366
- * jsonString,
1367
- * currentMetrics,
1368
- * currentUnlocked,
1369
- * { mergeStrategy: 'merge', validate: true }
1370
- * );
1371
- *
1372
- * if (result.success) {
1373
- * console.log(`Imported ${result.imported.achievements} achievements`);
1374
- * } else {
1375
- * console.error('Import failed:', result.errors);
1376
- * }
1377
- * ```
1378
- */
1379
- function importAchievementData(jsonString, currentMetrics, currentUnlocked, options = {}) {
1380
- const { mergeStrategy = 'replace', validate = true, expectedConfigHash } = options;
1381
- const warnings = [];
1382
- // Parse JSON
1383
- let data;
1384
- try {
1385
- data = JSON.parse(jsonString);
1386
- }
1387
- catch (_a) {
1388
- return {
1389
- success: false,
1390
- imported: { metrics: 0, achievements: 0 },
1391
- errors: ['Invalid JSON format']
1392
- };
1393
- }
1394
- // Validate structure
1395
- if (validate) {
1396
- const validationErrors = validateExportedData(data, expectedConfigHash);
1397
- if (validationErrors.length > 0) {
1398
- return {
1399
- success: false,
1400
- imported: { metrics: 0, achievements: 0 },
1401
- errors: validationErrors
1402
- };
1403
- }
1404
- }
1405
- // Version compatibility check
1406
- if (data.version && data.version !== '3.3.0') {
1407
- warnings.push(`Data exported from version ${data.version}, current version is 3.3.0`);
1408
- }
1409
- // Merge metrics based on strategy
1410
- let mergedMetrics;
1411
- let mergedUnlocked;
1412
- switch (mergeStrategy) {
1413
- case 'replace':
1414
- // Replace all existing data
1415
- mergedMetrics = data.metrics;
1416
- mergedUnlocked = data.unlockedAchievements;
1417
- break;
1418
- case 'merge':
1419
- // Union of both datasets, keeping higher metric values
1420
- mergedMetrics = mergeMetrics(currentMetrics, data.metrics);
1421
- mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
1422
- break;
1423
- case 'preserve':
1424
- // Keep existing values, only add new ones
1425
- mergedMetrics = preserveMetrics(currentMetrics, data.metrics);
1426
- mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
1427
- break;
1428
- default:
1429
- return {
1430
- success: false,
1431
- imported: { metrics: 0, achievements: 0 },
1432
- errors: [`Invalid merge strategy: ${mergeStrategy}`]
1433
- };
1434
- }
1435
- return Object.assign(Object.assign({ success: true, imported: {
1436
- metrics: Object.keys(mergedMetrics).length,
1437
- achievements: mergedUnlocked.length
1438
- } }, (warnings.length > 0 && { warnings })), { mergedMetrics,
1439
- mergedUnlocked });
1440
- }
1441
- /**
1442
- * Validates the structure and content of exported data
1443
- */
1444
- function validateExportedData(data, expectedConfigHash) {
1445
- const errors = [];
1446
- // Check required fields
1447
- if (!data.version) {
1448
- errors.push('Missing version field');
1449
- }
1450
- if (!data.timestamp) {
1451
- errors.push('Missing timestamp field');
1452
- }
1453
- if (!data.metrics || typeof data.metrics !== 'object') {
1454
- errors.push('Missing or invalid metrics field');
1455
- }
1456
- if (!Array.isArray(data.unlockedAchievements)) {
1457
- errors.push('Missing or invalid unlockedAchievements field');
1458
- }
1459
- // Validate config hash if provided
1460
- if (expectedConfigHash && data.configHash && data.configHash !== expectedConfigHash) {
1461
- errors.push('Configuration mismatch: imported data may not be compatible with current achievement configuration');
1462
- }
1463
- // Validate metrics structure
1464
- if (data.metrics && typeof data.metrics === 'object') {
1465
- for (const [key, value] of Object.entries(data.metrics)) {
1466
- if (!Array.isArray(value)) {
1467
- errors.push(`Invalid metric format for "${key}": expected array, got ${typeof value}`);
1468
- }
1469
- }
1470
- }
1471
- // Validate achievement IDs are strings
1472
- if (Array.isArray(data.unlockedAchievements)) {
1473
- const invalidIds = data.unlockedAchievements.filter((id) => typeof id !== 'string');
1474
- if (invalidIds.length > 0) {
1475
- errors.push('All achievement IDs must be strings');
1476
- }
1477
- }
1478
- return errors;
1479
- }
1480
- /**
1481
- * Merges two metrics objects, keeping higher values for overlapping keys
1482
- */
1483
- function mergeMetrics(current, imported) {
1484
- const merged = Object.assign({}, current);
1485
- for (const [key, importedValues] of Object.entries(imported)) {
1486
- if (!merged[key]) {
1487
- // New metric, add it
1488
- merged[key] = importedValues;
1489
- }
1490
- else {
1491
- // Existing metric, merge values
1492
- merged[key] = mergeMetricValues(merged[key], importedValues);
1493
- }
1494
- }
1495
- return merged;
1496
- }
1497
- /**
1498
- * Merges two metric value arrays, keeping higher numeric values
1499
- */
1500
- function mergeMetricValues(current, imported) {
1501
- // For simplicity, we'll use the imported values if they're "higher"
1502
- // This works for numeric values; for other types, we prefer imported
1503
- const currentValue = current[0];
1504
- const importedValue = imported[0];
1505
- // If both are numbers, keep the higher one
1506
- if (typeof currentValue === 'number' && typeof importedValue === 'number') {
1507
- return currentValue >= importedValue ? current : imported;
1508
- }
1509
- // For non-numeric values, prefer imported (assume it's newer)
1510
- return imported;
1511
- }
1512
- /**
1513
- * Preserves existing metrics, only adding new ones from imported data
1514
- */
1515
- function preserveMetrics(current, imported) {
1516
- const preserved = Object.assign({}, current);
1517
- for (const [key, value] of Object.entries(imported)) {
1518
- if (!preserved[key]) {
1519
- // Only add if it doesn't exist
1520
- preserved[key] = value;
1521
- }
1522
- // If it exists, keep current value (preserve strategy)
1523
- }
1524
- return preserved;
1525
- }
1526
-
1527
- /**
1528
- * Built-in notification component
1529
- * Modern, theme-aware achievement notification with smooth animations
1530
- */
1531
- const BuiltInNotification = ({ achievement, onClose, duration = 5000, position = 'top-center', theme = 'modern', }) => {
1532
- var _a, _b, _c;
1533
- const [isVisible, setIsVisible] = useState(false);
1534
- const [isExiting, setIsExiting] = useState(false);
1535
- // Get theme configuration
1536
- const themeConfig = getTheme(theme) || builtInThemes.modern;
1537
- const { notification: themeStyles } = themeConfig;
1538
- useEffect(() => {
1539
- // Slide in animation
1540
- const showTimer = setTimeout(() => setIsVisible(true), 10);
1541
- // Auto-dismiss
1542
- const dismissTimer = setTimeout(() => {
1543
- setIsExiting(true);
1544
- setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
1545
- }, duration);
1546
- return () => {
1547
- clearTimeout(showTimer);
1548
- clearTimeout(dismissTimer);
1549
- };
1550
- }, [duration, onClose]);
1551
- const getPositionStyles = () => {
1552
- const base = {
1553
- position: 'fixed',
1554
- zIndex: 9999,
1555
- };
1556
- switch (position) {
1557
- case 'top-center':
1558
- return Object.assign(Object.assign({}, base), { top: 20, left: '50%', transform: 'translateX(-50%)' });
1559
- case 'top-left':
1560
- return Object.assign(Object.assign({}, base), { top: 20, left: 20 });
1561
- case 'top-right':
1562
- return Object.assign(Object.assign({}, base), { top: 20, right: 20 });
1563
- case 'bottom-center':
1564
- return Object.assign(Object.assign({}, base), { bottom: 20, left: '50%', transform: 'translateX(-50%)' });
1565
- case 'bottom-left':
1566
- return Object.assign(Object.assign({}, base), { bottom: 20, left: 20 });
1567
- case 'bottom-right':
1568
- return Object.assign(Object.assign({}, base), { bottom: 20, right: 20 });
1569
- default:
1570
- return Object.assign(Object.assign({}, base), { top: 20, left: '50%', transform: 'translateX(-50%)' });
1571
- }
1572
- };
1573
- const containerStyles = Object.assign(Object.assign({}, getPositionStyles()), { background: themeStyles.background, borderRadius: themeStyles.borderRadius, boxShadow: themeStyles.boxShadow, padding: '16px 24px', minWidth: '320px', maxWidth: '500px', display: 'flex', alignItems: 'center', gap: '16px', opacity: isVisible && !isExiting ? 1 : 0, transform: position.startsWith('top')
1574
- ? `translateY(${isVisible && !isExiting ? '0' : '-20px'}) ${position.includes('center') ? 'translateX(-50%)' : ''}`
1575
- : `translateY(${isVisible && !isExiting ? '0' : '20px'}) ${position.includes('center') ? 'translateX(-50%)' : ''}`, transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', pointerEvents: isVisible ? 'auto' : 'none' });
1576
- const iconStyles = {
1577
- fontSize: '48px',
1578
- lineHeight: 1,
1579
- flexShrink: 0,
1580
- };
1581
- const contentStyles = {
1582
- flex: 1,
1583
- color: themeStyles.textColor,
1584
- minWidth: 0,
1585
- };
1586
- const headerStyles = {
1587
- fontSize: ((_a = themeStyles.fontSize) === null || _a === void 0 ? void 0 : _a.header) || '12px',
1588
- textTransform: 'uppercase',
1589
- letterSpacing: '1px',
1590
- opacity: 0.8,
1591
- marginBottom: '4px',
1592
- color: themeStyles.accentColor,
1593
- fontWeight: 600,
1594
- };
1595
- const titleStyles = {
1596
- fontSize: ((_b = themeStyles.fontSize) === null || _b === void 0 ? void 0 : _b.title) || '18px',
1597
- fontWeight: 'bold',
1598
- marginBottom: '4px',
1599
- overflow: 'hidden',
1600
- textOverflow: 'ellipsis',
1601
- whiteSpace: 'nowrap',
1602
- };
1603
- const descriptionStyles = {
1604
- fontSize: ((_c = themeStyles.fontSize) === null || _c === void 0 ? void 0 : _c.description) || '14px',
1605
- opacity: 0.9,
1606
- overflow: 'hidden',
1607
- textOverflow: 'ellipsis',
1608
- display: '-webkit-box',
1609
- WebkitLineClamp: 2,
1610
- WebkitBoxOrient: 'vertical',
1611
- };
1612
- const closeButtonStyles = {
1613
- background: 'none',
1614
- border: 'none',
1615
- color: themeStyles.textColor,
1616
- fontSize: '24px',
1617
- cursor: 'pointer',
1618
- opacity: 0.6,
1619
- transition: 'opacity 0.2s',
1620
- padding: '4px',
1621
- flexShrink: 0,
1622
- lineHeight: 1,
1623
- };
1624
- return (React.createElement("div", { style: containerStyles, "data-testid": "built-in-notification" },
1625
- React.createElement("div", { style: iconStyles }, achievement.icon),
1626
- React.createElement("div", { style: contentStyles },
1627
- React.createElement("div", { style: headerStyles }, "Achievement Unlocked!"),
1628
- React.createElement("div", { style: titleStyles }, achievement.title),
1629
- achievement.description && (React.createElement("div", { style: descriptionStyles }, achievement.description))),
1630
- React.createElement("button", { onClick: () => {
1631
- setIsExiting(true);
1632
- setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
1633
- }, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close notification" }, "\u00D7")));
1634
- };
1635
-
1636
- /**
1637
- * Built-in confetti component
1638
- * Lightweight CSS-based confetti animation
1639
- */
1640
- const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'], }) => {
1641
- const [isVisible, setIsVisible] = useState(false);
1642
- const { width, height } = useWindowSize();
1643
- useEffect(() => {
1644
- if (show) {
1645
- setIsVisible(true);
1646
- const timer = setTimeout(() => setIsVisible(false), duration);
1647
- return () => clearTimeout(timer);
1648
- }
1649
- else {
1650
- setIsVisible(false);
1651
- }
1652
- }, [show, duration]);
1653
- if (!isVisible)
1654
- return null;
1655
- const containerStyles = {
1656
- position: 'fixed',
1657
- top: 0,
1658
- left: 0,
1659
- width: '100%',
1660
- height: '100%',
1661
- pointerEvents: 'none',
1662
- zIndex: 10001,
1663
- overflow: 'hidden',
1664
- };
1665
- // Generate particles
1666
- const particles = Array.from({ length: particleCount }, (_, i) => {
1667
- const color = colors[Math.floor(Math.random() * colors.length)];
1668
- const startX = Math.random() * width;
1669
- const rotation = Math.random() * 360;
1670
- const fallDuration = 3 + Math.random() * 2; // 3-5 seconds
1671
- const delay = Math.random() * 0.5; // 0-0.5s delay
1672
- const shape = Math.random() > 0.5 ? 'circle' : 'square';
1673
- const particleStyles = {
1674
- position: 'absolute',
1675
- top: '-20px',
1676
- left: `${startX}px`,
1677
- width: '10px',
1678
- height: '10px',
1679
- backgroundColor: color,
1680
- borderRadius: shape === 'circle' ? '50%' : '0',
1681
- transform: `rotate(${rotation}deg)`,
1682
- animation: `confettiFall ${fallDuration}s linear ${delay}s forwards`,
1683
- opacity: 0.9,
1684
- };
1685
- return React.createElement("div", { key: i, style: particleStyles, "data-testid": "confetti-particle" });
1686
- });
1687
- return (React.createElement(React.Fragment, null,
1688
- React.createElement("style", null, `
1689
- @keyframes confettiFall {
1690
- 0% {
1691
- transform: translateY(0) rotate(0deg);
1692
- opacity: 1;
1693
- }
1694
- 100% {
1695
- transform: translateY(${height + 50}px) rotate(720deg);
1696
- opacity: 0;
1697
- }
1698
- }
1699
- `),
1700
- React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
1701
- };
1702
-
1703
- /**
1704
- * Legacy UI library detection system
1705
- * Attempts to dynamically import external UI libraries
1706
- * Shows deprecation warnings when detected
1707
- */
1708
- let cachedLibraries = null;
1709
- let detectionAttempted = false;
1710
- let deprecationWarningShown = false;
1711
- /**
1712
- * Attempts to dynamically import legacy UI libraries
1713
- * Uses try/catch to gracefully handle missing dependencies
1714
- * Caches result to avoid multiple import attempts
1715
- *
1716
- * @returns Promise resolving to LegacyLibraries object
1717
- */
1718
- function detectLegacyLibraries() {
1719
- return __awaiter(this, void 0, void 0, function* () {
1720
- if (detectionAttempted && cachedLibraries !== null) {
1721
- return cachedLibraries;
1722
- }
1723
- detectionAttempted = true;
1724
- const libraries = {};
1725
- // Try to import react-toastify
1726
- try {
1727
- const toastifyModule = yield import('react-toastify');
1728
- libraries.toast = toastifyModule.toast;
1729
- libraries.ToastContainer = toastifyModule.ToastContainer;
1730
- }
1731
- catch (_a) {
1732
- // Not installed, will use built-in notification
1733
- }
1734
- // Try to import react-modal
1735
- try {
1736
- const modalModule = yield import('react-modal');
1737
- libraries.Modal = modalModule.default;
1738
- }
1739
- catch (_b) {
1740
- // Not installed, will use built-in modal
1741
- }
1742
- // Try to import react-confetti
1743
- try {
1744
- const confettiModule = yield import('react-confetti');
1745
- libraries.Confetti = confettiModule.default;
1746
- }
1747
- catch (_c) {
1748
- // Not installed, will use built-in confetti
1749
- }
1750
- // Try to import react-use (only for useWindowSize)
1751
- try {
1752
- const reactUseModule = yield import('react-use');
1753
- libraries.useWindowSize = reactUseModule.useWindowSize;
1754
- }
1755
- catch (_d) {
1756
- // Not installed, will use built-in useWindowSize
1757
- }
1758
- cachedLibraries = libraries;
1759
- // Show deprecation warning if ANY legacy library is found
1760
- if (!deprecationWarningShown && Object.keys(libraries).length > 0) {
1761
- const foundLibraries = [];
1762
- if (libraries.toast)
1763
- foundLibraries.push('react-toastify');
1764
- if (libraries.Modal)
1765
- foundLibraries.push('react-modal');
1766
- if (libraries.Confetti)
1767
- foundLibraries.push('react-confetti');
1768
- if (libraries.useWindowSize)
1769
- foundLibraries.push('react-use');
1770
- console.warn(`[react-achievements] DEPRECATION WARNING: External UI dependencies (${foundLibraries.join(', ')}) are deprecated and will become fully optional in v4.0.0.\n\n` +
1771
- `The library now includes built-in UI components with modern design and theme support.\n\n` +
1772
- `To migrate:\n` +
1773
- `1. Add "useBuiltInUI={true}" to your AchievementProvider\n` +
1774
- `2. Test your application (UI will change to modern theme)\n` +
1775
- `3. Optionally customize with theme="minimal" or theme="gamified"\n` +
1776
- `4. Remove external dependencies from package.json\n\n` +
1777
- `To silence this warning, set useBuiltInUI={true} in AchievementProvider.\n\n` +
1778
- `Learn more: https://github.com/dave-b-b/react-achievements#migration-guide`);
1779
- deprecationWarningShown = true;
1780
- }
1781
- return libraries;
1782
- });
1783
- }
1784
-
1785
- /**
1786
- * Built-in modal component
1787
- * Modern, theme-aware achievement modal with smooth animations
1788
- */
1789
- const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'modern', }) => {
1790
- // Merge custom icons with defaults
1791
- const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
1792
- // Get theme configuration
1793
- const themeConfig = getTheme(theme) || builtInThemes.modern;
1794
- const { modal: themeStyles } = themeConfig;
1795
- useEffect(() => {
1796
- if (isOpen) {
1797
- // Lock body scroll when modal is open
1798
- document.body.style.overflow = 'hidden';
1799
- }
1800
- else {
1801
- // Restore body scroll
1802
- document.body.style.overflow = '';
1803
- }
1804
- return () => {
1805
- document.body.style.overflow = '';
1806
- };
1807
- }, [isOpen]);
1808
- if (!isOpen)
1809
- return null;
1810
- const overlayStyles = {
1811
- position: 'fixed',
1812
- top: 0,
1813
- left: 0,
1814
- right: 0,
1815
- bottom: 0,
1816
- backgroundColor: themeStyles.overlayColor,
1817
- display: 'flex',
1818
- alignItems: 'center',
1819
- justifyContent: 'center',
1820
- zIndex: 10000,
1821
- animation: 'fadeIn 0.3s ease-in-out',
1822
- };
1823
- const modalStyles = {
1824
- background: themeStyles.background,
1825
- borderRadius: themeStyles.borderRadius,
1826
- padding: '32px',
1827
- maxWidth: '600px',
1828
- width: '90%',
1829
- maxHeight: '80vh',
1830
- overflow: 'auto',
1831
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
1832
- animation: 'scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
1833
- position: 'relative',
1834
- };
1835
- const headerStyles = {
1836
- display: 'flex',
1837
- justifyContent: 'space-between',
1838
- alignItems: 'center',
1839
- marginBottom: '24px',
1840
- };
1841
- const titleStyles = {
1842
- margin: 0,
1843
- color: themeStyles.textColor,
1844
- fontSize: themeStyles.headerFontSize || '28px',
1845
- fontWeight: 'bold',
1846
- };
1847
- const closeButtonStyles = {
1848
- background: 'none',
1849
- border: 'none',
1850
- fontSize: '32px',
1851
- cursor: 'pointer',
1852
- color: themeStyles.textColor,
1853
- opacity: 0.6,
1854
- transition: 'opacity 0.2s',
1855
- padding: 0,
1856
- lineHeight: 1,
1857
- };
1858
- const isBadgeLayout = themeStyles.achievementLayout === 'badge';
1859
- const listStyles = isBadgeLayout
1860
- ? {
1861
- display: 'grid',
1862
- gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
1863
- gap: '16px',
1864
- }
1865
- : {
1866
- display: 'flex',
1867
- flexDirection: 'column',
1868
- gap: '12px',
1869
- };
1870
- const getAchievementItemStyles = (isUnlocked) => {
1871
- const baseStyles = {
1872
- borderRadius: themeStyles.achievementCardBorderRadius || '12px',
1873
- backgroundColor: isUnlocked
1874
- ? `${themeStyles.accentColor}1A` // 10% opacity
1875
- : 'rgba(255, 255, 255, 0.05)',
1876
- border: `2px solid ${isUnlocked ? themeStyles.accentColor : 'rgba(255, 255, 255, 0.1)'}`,
1877
- opacity: isUnlocked ? 1 : 0.5,
1878
- transition: 'all 0.2s',
1879
- };
1880
- if (isBadgeLayout) {
1881
- // Badge layout: vertical, centered, square-ish
1882
- return Object.assign(Object.assign({}, baseStyles), { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '20px 12px', aspectRatio: '1 / 1.1', minHeight: '160px' });
1883
- }
1884
- else {
1885
- // Horizontal layout (default)
1886
- return Object.assign(Object.assign({}, baseStyles), { display: 'flex', gap: '16px', padding: '16px' });
1887
- }
1888
- };
1889
- const getIconContainerStyles = (isUnlocked) => {
1890
- if (isBadgeLayout) {
1891
- return {
1892
- fontSize: '48px',
1893
- lineHeight: 1,
1894
- marginBottom: '8px',
1895
- opacity: isUnlocked ? 1 : 0.3,
1896
- };
1897
- }
1898
- return {
1899
- fontSize: '40px',
1900
- flexShrink: 0,
1901
- lineHeight: 1,
1902
- opacity: isUnlocked ? 1 : 0.3,
1903
- };
1904
- };
1905
- const contentStyles = isBadgeLayout
1906
- ? {
1907
- width: '100%',
1908
- }
1909
- : {
1910
- flex: 1,
1911
- minWidth: 0,
1912
- };
1913
- const achievementTitleStyles = isBadgeLayout
1914
- ? {
1915
- margin: '0 0 4px 0',
1916
- color: themeStyles.textColor,
1917
- fontSize: '14px',
1918
- fontWeight: 'bold',
1919
- lineHeight: '1.3',
1920
- }
1921
- : {
1922
- margin: '0 0 8px 0',
1923
- color: themeStyles.textColor,
1924
- fontSize: '18px',
1925
- fontWeight: 'bold',
1926
- overflow: 'hidden',
1927
- textOverflow: 'ellipsis',
1928
- whiteSpace: 'nowrap',
1929
- };
1930
- const achievementDescriptionStyles = isBadgeLayout
1931
- ? {
1932
- margin: 0,
1933
- color: themeStyles.textColor,
1934
- opacity: 0.7,
1935
- fontSize: '11px',
1936
- lineHeight: '1.3',
1937
- }
1938
- : {
1939
- margin: 0,
1940
- color: themeStyles.textColor,
1941
- opacity: 0.8,
1942
- fontSize: '14px',
1943
- };
1944
- const getLockIconStyles = () => {
1945
- if (isBadgeLayout) {
1946
- return {
1947
- position: 'absolute',
1948
- top: '8px',
1949
- right: '8px',
1950
- fontSize: '18px',
1951
- opacity: 0.6,
1952
- };
1953
- }
1954
- return {
1955
- fontSize: '24px',
1956
- flexShrink: 0,
1957
- opacity: 0.5,
1958
- };
1959
- };
1960
- return (React.createElement(React.Fragment, null,
1961
- React.createElement("style", null, `
1962
- @keyframes fadeIn {
1963
- from { opacity: 0; }
1964
- to { opacity: 1; }
1965
- }
1966
- @keyframes scaleIn {
1967
- from {
1968
- transform: scale(0.9);
1969
- opacity: 0;
1970
- }
1971
- to {
1972
- transform: scale(1);
1973
- opacity: 1;
1974
- }
1975
- }
1976
- `),
1977
- React.createElement("div", { style: overlayStyles, onClick: onClose, "data-testid": "built-in-modal-overlay" },
1978
- React.createElement("div", { style: modalStyles, onClick: (e) => e.stopPropagation(), "data-testid": "built-in-modal" },
1979
- React.createElement("div", { style: headerStyles },
1980
- React.createElement("h2", { style: titleStyles }, "\uD83C\uDFC6 Achievements"),
1981
- React.createElement("button", { onClick: onClose, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close modal" }, "\u00D7")),
1982
- React.createElement("div", { style: listStyles }, achievements.length === 0 ? (React.createElement("div", { style: { textAlign: 'center', padding: '40px 20px', color: themeStyles.textColor, opacity: 0.6 } }, "No achievements yet. Start exploring to unlock them!")) : (achievements.map((achievement) => {
1983
- const icon = (achievement.achievementIconKey &&
1984
- mergedIcons[achievement.achievementIconKey]) ||
1985
- mergedIcons.default ||
1986
- '⭐';
1987
- return (React.createElement("div", { key: achievement.achievementId, style: Object.assign(Object.assign({}, getAchievementItemStyles(achievement.isUnlocked)), { position: isBadgeLayout ? 'relative' : 'static' }) },
1988
- React.createElement("div", { style: getIconContainerStyles(achievement.isUnlocked) }, icon),
1989
- React.createElement("div", { style: contentStyles },
1990
- React.createElement("h3", { style: achievementTitleStyles }, achievement.achievementTitle),
1991
- React.createElement("p", { style: achievementDescriptionStyles }, achievement.achievementDescription)),
1992
- !achievement.isUnlocked && (React.createElement("div", { style: getLockIconStyles() }, "\uD83D\uDD12"))));
1993
- })))))));
1994
- };
1995
-
1996
- /**
1997
- * Legacy library wrappers for backwards compatibility
1998
- * Wraps external UI libraries to match our component interfaces
1999
- */
2000
- /**
2001
- * Wrapper for react-toastify toast notifications
2002
- * Falls back to built-in notification if not available
2003
- */
2004
- const createLegacyToastNotification = (libraries) => {
2005
- return ({ achievement, onClose }) => {
2006
- const { toast } = libraries;
2007
- useEffect(() => {
2008
- if (!toast)
2009
- return;
2010
- // Call toast.success with achievement content
2011
- toast.success(React.createElement("div", { style: { display: 'flex', alignItems: 'center' } },
2012
- React.createElement("span", { style: { fontSize: '2em', marginRight: '10px' } }, achievement.icon),
2013
- React.createElement("div", null,
2014
- React.createElement("div", { style: { fontSize: '12px', opacity: 0.8, marginBottom: '4px' } }, "Achievement Unlocked!"),
2015
- React.createElement("div", { style: { fontWeight: 'bold', marginBottom: '4px' } }, achievement.title),
2016
- achievement.description && (React.createElement("div", { style: { fontSize: '13px', opacity: 0.9 } }, achievement.description)))), {
2017
- position: 'top-right',
2018
- autoClose: 5000,
2019
- hideProgressBar: false,
2020
- closeOnClick: true,
2021
- pauseOnHover: true,
2022
- draggable: true,
2023
- toastId: achievement.id,
2024
- onClose,
2025
- });
2026
- }, [achievement, toast, onClose]);
2027
- return null; // Toast handles its own rendering
2028
- };
2029
- };
2030
- /**
2031
- * Wrapper for react-confetti Confetti component
2032
- * Falls back to built-in confetti if not available
2033
- */
2034
- const createLegacyConfettiWrapper = (libraries) => {
2035
- return ({ show, duration = 5000, particleCount = 200, colors }) => {
2036
- const { Confetti, useWindowSize: legacyUseWindowSize } = libraries;
2037
- // If Confetti not available, use built-in
2038
- if (!Confetti) {
2039
- return (React.createElement(BuiltInConfetti, { show: show, duration: duration, particleCount: particleCount, colors: colors }));
2040
- }
2041
- // Use react-confetti with react-use's useWindowSize if available
2042
- // Otherwise fall back to default dimensions
2043
- let width = 0;
2044
- let height = 0;
2045
- if (legacyUseWindowSize) {
2046
- const size = legacyUseWindowSize();
2047
- width = size.width;
2048
- height = size.height;
2049
- }
2050
- else if (typeof window !== 'undefined') {
2051
- width = window.innerWidth;
2052
- height = window.innerHeight;
2053
- }
2054
- if (!show)
2055
- return null;
2056
- return (React.createElement(Confetti, { width: width, height: height, numberOfPieces: particleCount, recycle: false, colors: colors, style: {
2057
- position: 'fixed',
2058
- top: 0,
2059
- left: 0,
2060
- zIndex: 10001,
2061
- pointerEvents: 'none',
2062
- } }));
2063
- };
2064
- };
2065
-
2066
- const AchievementContext = createContext(undefined);
2067
- const AchievementProvider = ({ achievements: achievementsConfig, storage = StorageType.Local, children, icons = {}, onError, restApiConfig, ui = {}, useBuiltInUI = false, }) => {
2068
- // Normalize the configuration to the complex format
2069
- const achievements = normalizeAchievements(achievementsConfig);
2070
- const [achievementState, setAchievementState] = useState({
2071
- unlocked: [],
2072
- all: achievements,
2073
- });
2074
- const [metrics, setMetrics] = useState({});
2075
- const seenAchievementsRef = useRef(new Set());
2076
- const initialLoadRef = useRef(false);
2077
- const storageRef = useRef(null);
2078
- const metricsUpdatedRef = useRef(false);
2079
- const [showConfetti, setShowConfetti] = useState(false);
2080
- const [_currentAchievement, setCurrentAchievement] = useState(null);
2081
- // NEW: UI component resolution state (v3.6.0)
2082
- const [legacyLibraries, setLegacyLibraries] = useState(null);
2083
- const [uiReady, setUiReady] = useState(useBuiltInUI); // Ready immediately if forcing built-in
2084
- const [currentNotification, setCurrentNotification] = useState(null);
2085
- if (!storageRef.current) {
2086
- if (typeof storage === 'string') {
2087
- // StorageType enum
2088
- switch (storage) {
2089
- case StorageType.Local:
2090
- storageRef.current = new LocalStorage('achievements');
2091
- break;
2092
- case StorageType.Memory:
2093
- storageRef.current = new MemoryStorage();
2094
- break;
2095
- case StorageType.IndexedDB: {
2096
- // Wrap async storage with adapter
2097
- const indexedDB = new IndexedDBStorage('react-achievements');
2098
- storageRef.current = new AsyncStorageAdapter(indexedDB, { onError });
2099
- break;
2100
- }
2101
- case StorageType.RestAPI: {
2102
- if (!restApiConfig) {
2103
- throw new ConfigurationError('restApiConfig is required when using StorageType.RestAPI');
2104
- }
2105
- // Wrap async storage with adapter
2106
- const restApi = new RestApiStorage(restApiConfig);
2107
- storageRef.current = new AsyncStorageAdapter(restApi, { onError });
2108
- break;
2109
- }
2110
- default:
2111
- throw new ConfigurationError(`Unsupported storage type: ${storage}`);
2112
- }
2113
- }
2114
- else {
2115
- // Custom storage instance
2116
- // Check if it's async storage and wrap with adapter
2117
- if (isAsyncStorage(storage)) {
2118
- storageRef.current = new AsyncStorageAdapter(storage, { onError });
2119
- }
2120
- else {
2121
- storageRef.current = storage;
2122
- }
2123
- }
2124
- }
2125
- const storageImpl = storageRef.current;
2126
- const getNotifiedAchievementsKey = () => {
2127
- return 'notifiedAchievements';
2128
- };
2129
- const loadNotifiedAchievements = () => {
2130
- var _a, _b;
2131
- try {
2132
- if (storageImpl instanceof LocalStorage) {
2133
- const data = localStorage.getItem(`achievements_${getNotifiedAchievementsKey()}`);
2134
- if (data) {
2135
- const notifiedAchievements = JSON.parse(data);
2136
- return new Set(notifiedAchievements);
2137
- }
2138
- }
2139
- else {
2140
- const data = (_b = (_a = storageImpl).getItem) === null || _b === void 0 ? void 0 : _b.call(_a, getNotifiedAchievementsKey());
2141
- if (data) {
2142
- return new Set(JSON.parse(data));
2143
- }
2144
- }
2145
- }
2146
- catch (e) {
2147
- console.error('Error loading notified achievements', e);
2148
- }
2149
- return new Set();
2150
- };
2151
- const saveNotifiedAchievements = (achievements) => {
2152
- var _a, _b;
2153
- try {
2154
- const achievementsArray = Array.from(achievements);
2155
- if (storageImpl instanceof LocalStorage) {
2156
- localStorage.setItem(`achievements_${getNotifiedAchievementsKey()}`, JSON.stringify(achievementsArray));
2157
- }
2158
- else {
2159
- (_b = (_a = storageImpl).setItem) === null || _b === void 0 ? void 0 : _b.call(_a, getNotifiedAchievementsKey(), JSON.stringify(achievementsArray));
2160
- }
2161
- }
2162
- catch (e) {
2163
- console.error('Error saving notified achievements', e);
2164
- }
2165
- };
2166
- // NEW: Detect legacy UI libraries on mount (v3.6.0)
2167
- useEffect(() => {
2168
- if (useBuiltInUI) {
2169
- // User explicitly wants built-in UI, skip detection
2170
- setUiReady(true);
2171
- return;
2172
- }
2173
- // Attempt to detect legacy libraries
2174
- detectLegacyLibraries().then((libs) => {
2175
- setLegacyLibraries(libs);
2176
- setUiReady(true);
2177
- });
2178
- }, [useBuiltInUI]);
2179
- // NEW: Resolve UI components based on detection and config (v3.6.0)
2180
- const NotificationComponent = ui.NotificationComponent ||
2181
- (useBuiltInUI ? BuiltInNotification :
2182
- legacyLibraries && Object.keys(legacyLibraries).length > 0 && legacyLibraries.toast
2183
- ? createLegacyToastNotification(legacyLibraries)
2184
- : BuiltInNotification);
2185
- const ConfettiComponentResolved = ui.ConfettiComponent ||
2186
- (useBuiltInUI ? BuiltInConfetti :
2187
- legacyLibraries && Object.keys(legacyLibraries).length > 0 && legacyLibraries.Confetti
2188
- ? createLegacyConfettiWrapper(legacyLibraries)
2189
- : BuiltInConfetti);
2190
- useEffect(() => {
2191
- if (!initialLoadRef.current) {
2192
- const savedUnlocked = storageImpl.getUnlockedAchievements() || [];
2193
- const savedMetrics = storageImpl.getMetrics() || {};
2194
- seenAchievementsRef.current = loadNotifiedAchievements();
2195
- if (savedUnlocked.length > 0 || Object.keys(savedMetrics).length > 0) {
2196
- setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: savedUnlocked })));
2197
- setMetrics(savedMetrics);
2198
- savedUnlocked.forEach(id => {
2199
- seenAchievementsRef.current.add(id);
2200
- });
2201
- saveNotifiedAchievements(seenAchievementsRef.current);
2202
- }
2203
- initialLoadRef.current = true;
2204
- }
2205
- }, []);
2206
- useEffect(() => {
2207
- if (Object.keys(metrics).length === 0 || !metricsUpdatedRef.current)
2208
- return;
2209
- metricsUpdatedRef.current = false;
2210
- const newlyUnlockedAchievements = [];
2211
- let achievementToShow = null;
2212
- Object.entries(achievements).forEach(([metricName, metricAchievements]) => {
2213
- metricAchievements.forEach((achievement) => {
2214
- const state = { metrics, unlockedAchievements: achievementState.unlocked };
2215
- const achievementId = achievement.achievementDetails.achievementId;
2216
- // For custom conditions, we always check against all metrics
2217
- // For threshold-based conditions, we check against the specific metric
2218
- const currentValue = metrics[metricName];
2219
- const shouldCheckAchievement = currentValue !== undefined ||
2220
- achievement.achievementDetails.achievementId.includes('_custom_');
2221
- if (shouldCheckAchievement) {
2222
- const valueToCheck = currentValue;
2223
- if (achievement.isConditionMet(valueToCheck, state)) {
2224
- if (!achievementState.unlocked.includes(achievementId) &&
2225
- !newlyUnlockedAchievements.includes(achievementId)) {
2226
- newlyUnlockedAchievements.push(achievementId);
2227
- if (!seenAchievementsRef.current.has(achievementId)) {
2228
- achievementToShow = achievement.achievementDetails;
2229
- }
2230
- }
2231
- }
2232
- }
2233
- });
2234
- });
2235
- if (newlyUnlockedAchievements.length > 0) {
2236
- const allUnlocked = [...achievementState.unlocked, ...newlyUnlockedAchievements];
2237
- setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: allUnlocked })));
2238
- storageImpl.setUnlockedAchievements(allUnlocked);
2239
- if (achievementToShow && (ui.enableNotifications !== false)) {
2240
- const achievement = achievementToShow;
2241
- // Get icon to display
2242
- let iconToDisplay = '🏆';
2243
- if (achievement.achievementIconKey && achievement.achievementIconKey in icons) {
2244
- iconToDisplay = icons[achievement.achievementIconKey];
2245
- }
2246
- // NEW: Use resolved notification component (v3.6.0)
2247
- setCurrentNotification({
2248
- achievement: {
2249
- id: achievement.achievementId || `achievement-${Date.now()}`,
2250
- title: achievement.achievementTitle || 'Achievement Unlocked!',
2251
- description: achievement.achievementDescription || '',
2252
- icon: iconToDisplay,
2253
- },
2254
- });
2255
- // Update seen achievements
2256
- if (achievement.achievementId) {
2257
- seenAchievementsRef.current.add(achievement.achievementId);
2258
- saveNotifiedAchievements(seenAchievementsRef.current);
2259
- }
2260
- // Show confetti
2261
- setCurrentAchievement(achievement);
2262
- setShowConfetti(true);
2263
- // Hide confetti after 5 seconds
2264
- const timer = setTimeout(() => {
2265
- setShowConfetti(false);
2266
- setCurrentAchievement(null);
2267
- }, 5000);
2268
- return () => clearTimeout(timer);
2269
- }
2270
- }
2271
- }, [metrics, achievementState.unlocked, achievements, icons]);
2272
- const getAllAchievements = () => {
2273
- const result = [];
2274
- // Iterate through all normalized achievements
2275
- Object.entries(achievements).forEach(([_metricName, metricAchievements]) => {
2276
- metricAchievements.forEach((achievement) => {
2277
- const { achievementDetails } = achievement;
2278
- const isUnlocked = achievementState.unlocked.includes(achievementDetails.achievementId);
2279
- result.push({
2280
- achievementId: achievementDetails.achievementId,
2281
- achievementTitle: achievementDetails.achievementTitle,
2282
- achievementDescription: achievementDetails.achievementDescription,
2283
- achievementIconKey: achievementDetails.achievementIconKey,
2284
- isUnlocked,
2285
- });
2286
- });
2287
- });
2288
- return result;
2289
- };
2290
- const update = (newMetrics) => {
2291
- metricsUpdatedRef.current = true;
2292
- const updatedMetrics = Object.assign({}, metrics);
2293
- Object.entries(newMetrics).forEach(([key, value]) => {
2294
- updatedMetrics[key] = value;
2295
- });
2296
- setMetrics(updatedMetrics);
2297
- const storageMetrics = Object.entries(updatedMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(value) ? value : [value] })), {});
2298
- try {
2299
- storageImpl.setMetrics(storageMetrics);
2300
- }
2301
- catch (error) {
2302
- if (error instanceof AchievementError) {
2303
- if (onError) {
2304
- onError(error);
2305
- }
2306
- else {
2307
- console.error('Achievement storage error:', error.message, error.remedy);
2308
- }
2309
- }
2310
- else {
2311
- console.error('Unexpected error saving metrics:', error);
2312
- }
2313
- }
2314
- };
2315
- const reset = () => {
2316
- var _a, _b;
2317
- storageImpl.clear();
2318
- if (storageImpl instanceof LocalStorage) {
2319
- localStorage.removeItem(`achievements_${getNotifiedAchievementsKey()}`);
2320
- }
2321
- else {
2322
- (_b = (_a = storageImpl).removeItem) === null || _b === void 0 ? void 0 : _b.call(_a, getNotifiedAchievementsKey());
2323
- }
2324
- setAchievementState({
2325
- unlocked: [],
2326
- all: achievements,
2327
- });
2328
- setMetrics({});
2329
- seenAchievementsRef.current.clear();
2330
- setShowConfetti(false);
2331
- setCurrentAchievement(null);
2332
- };
2333
- const getState = () => {
2334
- const metricsInArrayFormat = Object.entries(metrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(value) ? value : [value] })), {});
2335
- return {
2336
- metrics: metricsInArrayFormat,
2337
- unlocked: achievementState.unlocked,
2338
- };
2339
- };
2340
- const exportData = () => {
2341
- const state = getState();
2342
- const configHash = createConfigHash(achievementsConfig);
2343
- return exportAchievementData(state.metrics, state.unlocked, configHash);
2344
- };
2345
- const importData = (jsonString, options) => {
2346
- const state = getState();
2347
- const configHash = createConfigHash(achievementsConfig);
2348
- const result = importAchievementData(jsonString, state.metrics, state.unlocked, Object.assign(Object.assign({}, options), { expectedConfigHash: configHash }));
2349
- if (result.success && 'mergedMetrics' in result && 'mergedUnlocked' in result) {
2350
- // Apply the imported data
2351
- const mergedResult = result;
2352
- // Update metrics state
2353
- const metricsFromArrayFormat = Object.entries(mergedResult.mergedMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(value) ? value[0] : value })), {});
2354
- setMetrics(metricsFromArrayFormat);
2355
- // Update unlocked achievements state
2356
- setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: mergedResult.mergedUnlocked })));
2357
- // Persist to storage
2358
- storageImpl.setMetrics(mergedResult.mergedMetrics);
2359
- storageImpl.setUnlockedAchievements(mergedResult.mergedUnlocked);
2360
- // Update seen achievements to prevent duplicate notifications
2361
- mergedResult.mergedUnlocked.forEach(id => {
2362
- seenAchievementsRef.current.add(id);
2363
- });
2364
- saveNotifiedAchievements(seenAchievementsRef.current);
2365
- }
2366
- return result;
2367
- };
2368
- return (React.createElement(AchievementContext.Provider, { value: {
2369
- update,
2370
- achievements: achievementState,
2371
- reset,
2372
- getState,
2373
- exportData,
2374
- importData,
2375
- getAllAchievements,
2376
- } },
2377
- children,
2378
- uiReady && currentNotification && ui.enableNotifications !== false && (React.createElement(NotificationComponent, { achievement: currentNotification.achievement, onClose: () => setCurrentNotification(null), duration: 5000, position: ui.notificationPosition || 'top-center', theme: ui.theme || 'modern' })),
2379
- uiReady && ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: 5000 }))));
2380
- };
2381
-
2382
- const useAchievements = () => {
2383
- const context = useContext(AchievementContext);
2384
- if (!context) {
2385
- throw new Error('useAchievements must be used within an AchievementProvider');
2386
- }
2387
- return context;
2388
- };
2389
-
2390
- /**
2391
- * A simplified hook for achievement tracking.
2392
- * Provides an easier API for common use cases while maintaining access to advanced features.
2393
- */
2394
- const useSimpleAchievements = () => {
2395
- const { update, achievements, reset, getState, exportData, importData, getAllAchievements } = useAchievements();
2396
- return {
2397
- /**
2398
- * Track a metric value for achievements
2399
- * @param metric - The metric name (e.g., 'score', 'level')
2400
- * @param value - The metric value
2401
- */
2402
- track: (metric, value) => update({ [metric]: value }),
2403
- /**
2404
- * Increment a numeric metric by a specified amount
2405
- * @param metric - The metric name (e.g., 'buttonClicks', 'score')
2406
- * @param amount - The amount to increment by (defaults to 1)
2407
- */
2408
- increment: (metric, amount = 1) => {
2409
- const currentState = getState();
2410
- const currentMetricArray = currentState.metrics[metric] || [0];
2411
- const currentValue = Array.isArray(currentMetricArray) ? currentMetricArray[0] : currentMetricArray;
2412
- const newValue = (typeof currentValue === 'number' ? currentValue : 0) + amount;
2413
- update({ [metric]: newValue });
2414
- },
2415
- /**
2416
- * Track multiple metrics at once
2417
- * @param metrics - Object with metric names as keys and values
2418
- */
2419
- trackMultiple: (metrics) => update(metrics),
2420
- /**
2421
- * Array of unlocked achievement IDs
2422
- */
2423
- unlocked: achievements.unlocked,
2424
- /**
2425
- * All available achievements
2426
- */
2427
- all: achievements.all,
2428
- /**
2429
- * Number of unlocked achievements
2430
- */
2431
- unlockedCount: achievements.unlocked.length,
2432
- /**
2433
- * Reset all achievement progress
2434
- */
2435
- reset,
2436
- /**
2437
- * Get current state (advanced usage)
2438
- */
2439
- getState,
2440
- /**
2441
- * Export achievement data to JSON string
2442
- * @returns JSON string containing all achievement data
2443
- */
2444
- exportData,
2445
- /**
2446
- * Import achievement data from JSON string
2447
- * @param jsonString - JSON string containing exported achievement data
2448
- * @param options - Import options (merge strategy, validation)
2449
- * @returns Import result with success status and any errors
2450
- */
2451
- importData,
2452
- /**
2453
- * Get all achievements with their unlock status
2454
- * @returns Array of achievements with isUnlocked boolean property
2455
- */
2456
- getAllAchievements,
2457
- };
2458
- };
2459
-
2460
- /**
2461
- * Base class for chainable achievement configuration (Tier 2)
2462
- */
2463
- class Achievement {
2464
- constructor(metric, defaultAward) {
2465
- this.metric = metric;
2466
- this.award = defaultAward;
2467
- }
2468
- /**
2469
- * Customize the award details for this achievement
2470
- * @param award - Custom award details
2471
- * @returns This achievement for chaining
2472
- */
2473
- withAward(award) {
2474
- this.award = Object.assign(Object.assign({}, this.award), award);
2475
- return this;
2476
- }
2477
- }
2478
- /**
2479
- * Threshold-based achievement (score, level, etc.)
2480
- */
2481
- class ThresholdAchievement extends Achievement {
2482
- constructor(metric, threshold, defaultAward) {
2483
- super(metric, defaultAward);
2484
- this.threshold = threshold;
2485
- }
2486
- toConfig() {
2487
- return {
2488
- [this.metric]: {
2489
- [this.threshold]: {
2490
- title: this.award.title,
2491
- description: this.award.description,
2492
- icon: this.award.icon
2493
- }
2494
- }
2495
- };
2496
- }
2497
- }
2498
- /**
2499
- * Boolean achievement (tutorial completion, first login, etc.)
2500
- */
2501
- class BooleanAchievement extends Achievement {
2502
- toConfig() {
2503
- return {
2504
- [this.metric]: {
2505
- true: {
2506
- title: this.award.title,
2507
- description: this.award.description,
2508
- icon: this.award.icon
2509
- }
2510
- }
2511
- };
2512
- }
2513
- }
2514
- /**
2515
- * Value-based achievement (character class, difficulty, etc.)
2516
- */
2517
- class ValueAchievement extends Achievement {
2518
- constructor(metric, value, defaultAward) {
2519
- super(metric, defaultAward);
2520
- this.value = value;
2521
- }
2522
- toConfig() {
2523
- return {
2524
- [this.metric]: {
2525
- [this.value]: {
2526
- title: this.award.title,
2527
- description: this.award.description,
2528
- icon: this.award.icon
2529
- }
2530
- }
2531
- };
2532
- }
2533
- }
2534
- /**
2535
- * Complex achievement builder for power users (Tier 3)
2536
- */
2537
- class ComplexAchievementBuilder {
2538
- constructor() {
2539
- this.id = '';
2540
- this.metric = '';
2541
- this.condition = null;
2542
- this.award = {};
2543
- }
2544
- /**
2545
- * Set the unique identifier for this achievement
2546
- */
2547
- withId(id) {
2548
- this.id = id;
2549
- return this;
2550
- }
2551
- /**
2552
- * Set the metric this achievement tracks
2553
- */
2554
- withMetric(metric) {
2555
- this.metric = metric;
2556
- return this;
2557
- }
2558
- /**
2559
- * Set the condition function that determines if achievement is unlocked
2560
- */
2561
- withCondition(fn) {
2562
- this.condition = fn;
2563
- return this;
2564
- }
2565
- /**
2566
- * Set the award details for this achievement
2567
- */
2568
- withAward(award) {
2569
- this.award = Object.assign(Object.assign({}, this.award), award);
2570
- return this;
2571
- }
2572
- /**
2573
- * Build the final achievement configuration
2574
- */
2575
- build() {
2576
- if (!this.id || !this.metric || !this.condition) {
2577
- throw new Error('Complex achievement requires id, metric, and condition');
2578
- }
2579
- // Convert our two-parameter condition function to the single-parameter format
2580
- // expected by the existing CustomAchievementDetails type
2581
- const compatibleCondition = (metrics) => {
2582
- const state = {
2583
- metrics: {}, // We don't have access to the full metrics structure here
2584
- unlockedAchievements: []
2585
- };
2586
- return this.condition(metrics[this.metric], state);
2587
- };
2588
- return {
2589
- [this.id]: {
2590
- custom: {
2591
- title: this.award.title || this.id,
2592
- description: this.award.description || `Achieve ${this.award.title || this.id}`,
2593
- icon: this.award.icon || '💎',
2594
- condition: compatibleCondition
2595
- }
2596
- }
2597
- };
2598
- }
2599
- }
2600
- /**
2601
- * Main AchievementBuilder with three-tier API
2602
- * Tier 1: Simple static methods with smart defaults
2603
- * Tier 2: Chainable customization
2604
- * Tier 3: Full builder for complex logic
2605
- */
2606
- class AchievementBuilder {
2607
- // TIER 1: Simple Static Methods (90% of use cases)
2608
- /**
2609
- * Create a single score achievement with smart defaults
2610
- * @param threshold - Score threshold to achieve
2611
- * @returns Chainable ThresholdAchievement
2612
- */
2613
- static createScoreAchievement(threshold) {
2614
- return new ThresholdAchievement('score', threshold, {
2615
- title: `Score ${threshold}!`,
2616
- description: `Score ${threshold} points`,
2617
- icon: '🏆'
2618
- });
2619
- }
2620
- /**
2621
- * Create multiple score achievements
2622
- * @param thresholds - Array of thresholds or [threshold, award] tuples
2623
- * @returns Complete SimpleAchievementConfig
2624
- */
2625
- static createScoreAchievements(thresholds) {
2626
- const config = { score: {} };
2627
- thresholds.forEach(item => {
2628
- if (typeof item === 'number') {
2629
- // Use default award
2630
- config.score[item] = {
2631
- title: `Score ${item}!`,
2632
- description: `Score ${item} points`,
2633
- icon: '🏆'
2634
- };
2635
- }
2636
- else {
2637
- // Custom award
2638
- const [threshold, award] = item;
2639
- config.score[threshold] = {
2640
- title: award.title || `Score ${threshold}!`,
2641
- description: award.description || `Score ${threshold} points`,
2642
- icon: award.icon || '🏆'
2643
- };
2644
- }
2645
- });
2646
- return config;
2647
- }
2648
- /**
2649
- * Create a single level achievement with smart defaults
2650
- * @param level - Level threshold to achieve
2651
- * @returns Chainable ThresholdAchievement
2652
- */
2653
- static createLevelAchievement(level) {
2654
- return new ThresholdAchievement('level', level, {
2655
- title: `Level ${level}!`,
2656
- description: `Reach level ${level}`,
2657
- icon: '📈'
2658
- });
2659
- }
2660
- /**
2661
- * Create multiple level achievements
2662
- * @param levels - Array of levels or [level, award] tuples
2663
- * @returns Complete SimpleAchievementConfig
2664
- */
2665
- static createLevelAchievements(levels) {
2666
- const config = { level: {} };
2667
- levels.forEach(item => {
2668
- if (typeof item === 'number') {
2669
- // Use default award
2670
- config.level[item] = {
2671
- title: `Level ${item}!`,
2672
- description: `Reach level ${item}`,
2673
- icon: '📈'
2674
- };
2675
- }
2676
- else {
2677
- // Custom award
2678
- const [level, award] = item;
2679
- config.level[level] = {
2680
- title: award.title || `Level ${level}!`,
2681
- description: award.description || `Reach level ${level}`,
2682
- icon: award.icon || '📈'
2683
- };
2684
- }
2685
- });
2686
- return config;
2687
- }
2688
- /**
2689
- * Create a boolean achievement with smart defaults
2690
- * @param metric - The metric name (e.g., 'completedTutorial')
2691
- * @returns Chainable BooleanAchievement
2692
- */
2693
- static createBooleanAchievement(metric) {
2694
- // Convert camelCase to Title Case
2695
- const formattedMetric = metric.replace(/([A-Z])/g, ' $1').toLowerCase();
2696
- const titleCase = formattedMetric.charAt(0).toUpperCase() + formattedMetric.slice(1);
2697
- return new BooleanAchievement(metric, {
2698
- title: `${titleCase}!`,
2699
- description: `Complete ${formattedMetric}`,
2700
- icon: '✅'
2701
- });
2702
- }
2703
- /**
2704
- * Create a value-based achievement with smart defaults
2705
- * @param metric - The metric name (e.g., 'characterClass')
2706
- * @param value - The value to match (e.g., 'wizard')
2707
- * @returns Chainable ValueAchievement
2708
- */
2709
- static createValueAchievement(metric, value) {
2710
- const formattedValue = value.charAt(0).toUpperCase() + value.slice(1);
2711
- return new ValueAchievement(metric, value, {
2712
- title: `${formattedValue}!`,
2713
- description: `Choose ${formattedValue.toLowerCase()} for ${metric}`,
2714
- icon: '🎯'
2715
- });
2716
- }
2717
- // TIER 3: Full Builder for Complex Logic
2718
- /**
2719
- * Create a complex achievement builder for power users
2720
- * @returns ComplexAchievementBuilder for full control
2721
- */
2722
- static create() {
2723
- return new ComplexAchievementBuilder();
2724
- }
2725
- // UTILITY METHODS
2726
- /**
2727
- * Combine multiple achievement configurations
2728
- * @param achievements - Array of SimpleAchievementConfig objects or Achievement instances
2729
- * @returns Combined SimpleAchievementConfig
2730
- */
2731
- static combine(achievements) {
2732
- const combined = {};
2733
- achievements.forEach(achievement => {
2734
- const config = achievement instanceof Achievement ? achievement.toConfig() : achievement;
2735
- Object.keys(config).forEach(key => {
2736
- if (!combined[key]) {
2737
- combined[key] = {};
2738
- }
2739
- Object.assign(combined[key], config[key]);
2740
- });
2741
- });
2742
- return combined;
2743
- }
2744
- }
2745
-
2746
- export { AchievementBuilder, AchievementContext, AchievementError, AchievementProvider, AsyncStorageAdapter, BadgesButton, BadgesModal, BuiltInConfetti, BuiltInModal, BuiltInNotification, ConfettiWrapper, ConfigurationError, ImportValidationError, IndexedDBStorage, LocalStorage, MemoryStorage, OfflineQueueStorage, RestApiStorage, StorageError, StorageQuotaError, StorageType, SyncError, createConfigHash, defaultAchievementIcons, defaultStyles, exportAchievementData, importAchievementData, isAchievementError, isAsyncStorage, isRecoverableError, isSimpleConfig, normalizeAchievements, useAchievements, useSimpleAchievements, useWindowSize };
2747
- //# sourceMappingURL=index.js.map