react-achievements 3.7.0 → 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.
@@ -0,0 +1,1354 @@
1
+ import { AchievementEngine } from 'achievements-engine';
2
+ export { AchievementBuilder, AchievementEngine, AchievementError, AsyncStorageAdapter, ConfigurationError, ImportValidationError, IndexedDBStorage, LocalStorage, MemoryStorage, OfflineQueueStorage, RestApiStorage, StorageError, StorageQuotaError, StorageType, SyncError, createConfigHash, exportAchievementData, importAchievementData, isAchievementError, isRecoverableError, isSimpleConfig, normalizeAchievements } from 'achievements-engine';
3
+ import React, { useState, useEffect, createContext, useRef, useContext } from 'react';
4
+ import Modal from 'react-modal';
5
+ import Confetti from 'react-confetti';
6
+
7
+ // Type guard to detect async storage
8
+ function isAsyncStorage(storage) {
9
+ // Check if methods return Promises
10
+ const testResult = storage.getMetrics();
11
+ return testResult && typeof testResult.then === 'function';
12
+ }
13
+ var StorageType;
14
+ (function (StorageType) {
15
+ StorageType["Local"] = "local";
16
+ StorageType["Memory"] = "memory";
17
+ StorageType["IndexedDB"] = "indexeddb";
18
+ StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
19
+ })(StorageType || (StorageType = {}));
20
+
21
+ /**
22
+ * Built-in theme presets
23
+ */
24
+ const builtInThemes = {
25
+ /**
26
+ * Modern theme - Dark gradients with vibrant accents
27
+ * Inspired by contemporary achievement systems (Discord, Steam, Xbox)
28
+ */
29
+ modern: {
30
+ name: 'modern',
31
+ notification: {
32
+ background: 'linear-gradient(135deg, rgba(30, 30, 50, 0.98) 0%, rgba(50, 50, 70, 0.98) 100%)',
33
+ textColor: '#ffffff',
34
+ accentColor: '#4CAF50',
35
+ borderRadius: '12px',
36
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
37
+ fontSize: {
38
+ header: '12px',
39
+ title: '18px',
40
+ description: '14px',
41
+ },
42
+ },
43
+ modal: {
44
+ overlayColor: 'rgba(0, 0, 0, 0.85)',
45
+ background: 'linear-gradient(135deg, #1e1e32 0%, #323246 100%)',
46
+ textColor: '#ffffff',
47
+ accentColor: '#4CAF50',
48
+ borderRadius: '16px',
49
+ headerFontSize: '28px',
50
+ },
51
+ confetti: {
52
+ colors: ['#FFD700', '#4CAF50', '#2196F3', '#FF6B6B'],
53
+ particleCount: 50,
54
+ shapes: ['circle', 'square'],
55
+ },
56
+ },
57
+ /**
58
+ * Minimal theme - Clean, light design with subtle accents
59
+ * Perfect for professional or minimalist applications
60
+ */
61
+ minimal: {
62
+ name: 'minimal',
63
+ notification: {
64
+ background: 'rgba(255, 255, 255, 0.98)',
65
+ textColor: '#333333',
66
+ accentColor: '#4CAF50',
67
+ borderRadius: '8px',
68
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
69
+ fontSize: {
70
+ header: '11px',
71
+ title: '16px',
72
+ description: '13px',
73
+ },
74
+ },
75
+ modal: {
76
+ overlayColor: 'rgba(0, 0, 0, 0.5)',
77
+ background: '#ffffff',
78
+ textColor: '#333333',
79
+ accentColor: '#4CAF50',
80
+ borderRadius: '12px',
81
+ headerFontSize: '24px',
82
+ },
83
+ confetti: {
84
+ colors: ['#4CAF50', '#2196F3'],
85
+ particleCount: 30,
86
+ shapes: ['circle'],
87
+ },
88
+ },
89
+ /**
90
+ * Gamified theme - Modern gaming aesthetic with sci-fi colors
91
+ * Dark navy backgrounds with cyan and orange accents (2024 gaming trend)
92
+ * Features square/badge-shaped achievement cards
93
+ */
94
+ gamified: {
95
+ name: 'gamified',
96
+ notification: {
97
+ background: 'linear-gradient(135deg, rgba(5, 8, 22, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%)',
98
+ textColor: '#22d3ee', // Bright cyan
99
+ accentColor: '#f97316', // Bright orange
100
+ borderRadius: '6px',
101
+ boxShadow: '0 8px 32px rgba(34, 211, 238, 0.4), 0 0 20px rgba(249, 115, 22, 0.3)',
102
+ fontSize: {
103
+ header: '13px',
104
+ title: '20px',
105
+ description: '15px',
106
+ },
107
+ },
108
+ modal: {
109
+ overlayColor: 'rgba(5, 8, 22, 0.85)',
110
+ background: 'linear-gradient(135deg, #0f172a 0%, #050816 100%)',
111
+ textColor: '#22d3ee', // Bright cyan
112
+ accentColor: '#f97316', // Bright orange
113
+ borderRadius: '8px',
114
+ headerFontSize: '32px',
115
+ achievementCardBorderRadius: '8px', // Square badge-like cards
116
+ achievementLayout: 'badge', // Use badge/grid layout instead of horizontal list
117
+ },
118
+ confetti: {
119
+ colors: ['#22d3ee', '#f97316', '#a855f7', '#eab308'], // Cyan, orange, purple, yellow
120
+ particleCount: 100,
121
+ shapes: ['circle', 'square'],
122
+ },
123
+ },
124
+ };
125
+ /**
126
+ * Retrieve a theme by name (internal use only)
127
+ * Only checks built-in themes
128
+ *
129
+ * @param name - Theme name (built-in only)
130
+ * @returns Theme configuration or undefined if not found
131
+ * @internal
132
+ */
133
+ function getTheme(name) {
134
+ return builtInThemes[name];
135
+ }
136
+
137
+ const getPositionStyles = (position) => {
138
+ const base = {
139
+ position: 'fixed',
140
+ margin: '20px',
141
+ zIndex: 1000,
142
+ };
143
+ switch (position) {
144
+ case 'top-left':
145
+ return Object.assign(Object.assign({}, base), { top: 0, left: 0 });
146
+ case 'top-right':
147
+ return Object.assign(Object.assign({}, base), { top: 0, right: 0 });
148
+ case 'bottom-left':
149
+ return Object.assign(Object.assign({}, base), { bottom: 0, left: 0 });
150
+ case 'bottom-right':
151
+ return Object.assign(Object.assign({}, base), { bottom: 0, right: 0 });
152
+ }
153
+ };
154
+ const BadgesButton = ({ onClick, position = 'bottom-right', placement = 'fixed', styles = {}, unlockedAchievements, theme = 'modern', }) => {
155
+ // Get theme configuration for consistent styling
156
+ const themeConfig = getTheme(theme) || builtInThemes.modern;
157
+ const accentColor = themeConfig.notification.accentColor;
158
+ // Different styling for fixed vs inline placement
159
+ const baseStyles = placement === 'inline'
160
+ ? Object.assign({
161
+ // Inline mode: looks like a navigation item
162
+ 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({
163
+ // Fixed mode: floating button
164
+ 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);
165
+ return (React.createElement("button", { onClick: onClick, style: baseStyles, onMouseEnter: (e) => {
166
+ if (placement === 'inline') {
167
+ // Inline mode: subtle background color change
168
+ e.target.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
169
+ }
170
+ else {
171
+ // Fixed mode: scale transformation
172
+ e.target.style.transform = 'scale(1.05)';
173
+ }
174
+ }, onMouseLeave: (e) => {
175
+ if (placement === 'inline') {
176
+ e.target.style.backgroundColor = 'transparent';
177
+ }
178
+ else {
179
+ e.target.style.transform = 'scale(1)';
180
+ }
181
+ }, "data-placement": placement, "data-testid": "badges-button" },
182
+ "\uD83C\uDFC6 Achievements (",
183
+ unlockedAchievements.length,
184
+ ")"));
185
+ };
186
+
187
+ const defaultAchievementIcons = {
188
+ // Essential fallback icons for system use
189
+ default: '⭐', // Fallback when no icon is provided
190
+ loading: '⏳', // For loading states
191
+ error: '⚠️', // For error states
192
+ success: '✅', // For success states
193
+ // A few common icons for backward compatibility
194
+ trophy: '🏆',
195
+ star: '⭐',
196
+ };
197
+
198
+ const defaultStyles = {
199
+ badgesButton: {
200
+ backgroundColor: '#4CAF50',
201
+ color: 'white',
202
+ padding: '10px 20px',
203
+ border: 'none',
204
+ borderRadius: '20px',
205
+ cursor: 'pointer',
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ gap: '8px',
209
+ fontSize: '16px',
210
+ boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
211
+ transition: 'transform 0.2s ease-in-out',
212
+ },
213
+ badgesModal: {
214
+ overlay: {
215
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ justifyContent: 'center',
219
+ zIndex: 1000,
220
+ },
221
+ content: {
222
+ position: 'relative',
223
+ background: '#fff',
224
+ borderRadius: '8px',
225
+ padding: '20px',
226
+ maxWidth: '500px',
227
+ width: '90%',
228
+ maxHeight: '80vh',
229
+ overflow: 'auto',
230
+ },
231
+ header: {
232
+ display: 'flex',
233
+ justifyContent: 'space-between',
234
+ alignItems: 'center',
235
+ marginBottom: '20px',
236
+ },
237
+ closeButton: {
238
+ background: 'none',
239
+ border: 'none',
240
+ fontSize: '24px',
241
+ cursor: 'pointer',
242
+ padding: '0',
243
+ },
244
+ achievementList: {
245
+ display: 'flex',
246
+ flexDirection: 'column',
247
+ gap: '16px',
248
+ },
249
+ achievementItem: {
250
+ display: 'flex',
251
+ gap: '16px',
252
+ padding: '16px',
253
+ borderRadius: '8px',
254
+ backgroundColor: '#f5f5f5',
255
+ alignItems: 'center',
256
+ },
257
+ achievementTitle: {
258
+ margin: '0',
259
+ fontSize: '18px',
260
+ fontWeight: 'bold',
261
+ },
262
+ achievementDescription: {
263
+ margin: '4px 0 0 0',
264
+ color: '#666',
265
+ },
266
+ achievementIcon: {
267
+ fontSize: '32px',
268
+ display: 'flex',
269
+ alignItems: 'center',
270
+ justifyContent: 'center',
271
+ },
272
+ lockIcon: {
273
+ fontSize: '24px',
274
+ position: 'absolute',
275
+ top: '50%',
276
+ right: '16px',
277
+ transform: 'translateY(-50%)',
278
+ },
279
+ lockedAchievementItem: {
280
+ display: 'flex',
281
+ gap: '16px',
282
+ padding: '16px',
283
+ borderRadius: '8px',
284
+ backgroundColor: '#e0e0e0',
285
+ alignItems: 'center',
286
+ opacity: 0.5,
287
+ },
288
+ },
289
+ };
290
+
291
+ const BadgesModal = ({ isOpen, onClose, achievements, styles = {}, icons = {}, showAllAchievements = false, showUnlockConditions = false, allAchievements, }) => {
292
+ // Merge custom icons with default icons, with custom icons taking precedence
293
+ const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
294
+ return (React.createElement(Modal, { isOpen: isOpen, onRequestClose: onClose, style: {
295
+ overlay: Object.assign(Object.assign({}, defaultStyles.badgesModal.overlay), styles === null || styles === void 0 ? void 0 : styles.overlay),
296
+ content: Object.assign(Object.assign({}, defaultStyles.badgesModal.content), styles === null || styles === void 0 ? void 0 : styles.content)
297
+ }, contentLabel: "Achievements" },
298
+ React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.header), styles === null || styles === void 0 ? void 0 : styles.header) },
299
+ React.createElement("h2", { style: { margin: 0 } }, "\uD83C\uDFC6 Achievements"),
300
+ 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")),
301
+ React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementList), styles === null || styles === void 0 ? void 0 : styles.achievementList) }, (() => {
302
+ // Determine which achievements to display
303
+ const achievementsToDisplay = showAllAchievements && allAchievements
304
+ ? allAchievements
305
+ : achievements.map(a => (Object.assign(Object.assign({}, a), { isUnlocked: true })));
306
+ return (React.createElement(React.Fragment, null,
307
+ achievementsToDisplay.map((achievement) => {
308
+ const isLocked = !achievement.isUnlocked;
309
+ return (React.createElement("div", { key: achievement.achievementId, style: Object.assign(Object.assign(Object.assign({}, (isLocked
310
+ ? 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' }) },
311
+ 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
312
+ ? mergedIcons[achievement.achievementIconKey]
313
+ : mergedIcons.default || '⭐')),
314
+ React.createElement("div", { style: { flex: 1 } },
315
+ 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),
316
+ 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' }) },
317
+ achievement.achievementDescription,
318
+ showUnlockConditions && isLocked && achievement.achievementDescription && (React.createElement("span", { style: {
319
+ display: 'block',
320
+ fontSize: '12px',
321
+ marginTop: '4px',
322
+ fontStyle: 'italic',
323
+ color: '#888'
324
+ } },
325
+ "\uD83D\uDD13 ",
326
+ achievement.achievementDescription)))),
327
+ isLocked && (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.lockIcon), styles === null || styles === void 0 ? void 0 : styles.lockIcon) }, "\uD83D\uDD12"))));
328
+ }),
329
+ achievementsToDisplay.length === 0 && (React.createElement("p", { style: { textAlign: 'center', color: '#666' } }, "No achievements configured."))));
330
+ })())));
331
+ };
332
+
333
+ /**
334
+ * BadgesButtonWithModal - A convenience component that combines BadgesButton and BadgesModal
335
+ *
336
+ * This component manages the modal state internally, providing a simplified API
337
+ * for the common use case of displaying achievements.
338
+ *
339
+ * For advanced use cases requiring custom state management or multiple triggers,
340
+ * use BadgesButton and BadgesModal separately.
341
+ *
342
+ * @example
343
+ * ```tsx
344
+ * // Simple usage
345
+ * <BadgesButtonWithModal
346
+ * unlockedAchievements={achievements.unlocked}
347
+ * />
348
+ *
349
+ * // Show all achievements (locked + unlocked)
350
+ * <BadgesButtonWithModal
351
+ * unlockedAchievements={achievements.unlocked}
352
+ * showAllAchievements={true}
353
+ * allAchievements={getAllAchievements()}
354
+ * showUnlockConditions={true}
355
+ * />
356
+ *
357
+ * // Customize position and theme
358
+ * <BadgesButtonWithModal
359
+ * unlockedAchievements={achievements.unlocked}
360
+ * position="top-right"
361
+ * theme="gamified"
362
+ * />
363
+ *
364
+ * // Inline mode for navigation
365
+ * <BadgesButtonWithModal
366
+ * unlockedAchievements={achievements.unlocked}
367
+ * placement="inline"
368
+ * />
369
+ * ```
370
+ */
371
+ const BadgesButtonWithModal = ({ unlockedAchievements, position = 'bottom-right', placement = 'fixed', showAllAchievements = false, allAchievements, showUnlockConditions = false, icons, theme = 'modern', buttonStyles, modalStyles, }) => {
372
+ const [isModalOpen, setIsModalOpen] = useState(false);
373
+ return (React.createElement(React.Fragment, null,
374
+ React.createElement(BadgesButton, { onClick: () => setIsModalOpen(true), unlockedAchievements: unlockedAchievements, position: position, placement: placement, theme: theme, styles: buttonStyles }),
375
+ React.createElement(BadgesModal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), achievements: unlockedAchievements, showAllAchievements: showAllAchievements, allAchievements: allAchievements, showUnlockConditions: showUnlockConditions, icons: icons, styles: modalStyles })));
376
+ };
377
+
378
+ /**
379
+ * Hook to track window dimensions
380
+ * Replacement for react-use's useWindowSize
381
+ *
382
+ * @returns Object with width and height properties
383
+ *
384
+ * @example
385
+ * ```tsx
386
+ * const { width, height } = useWindowSize();
387
+ * console.log(`Window size: ${width}x${height}`);
388
+ * ```
389
+ */
390
+ function useWindowSize() {
391
+ const [size, setSize] = useState({
392
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
393
+ height: typeof window !== 'undefined' ? window.innerHeight : 0,
394
+ });
395
+ useEffect(() => {
396
+ // Handle SSR - window may not be defined
397
+ if (typeof window === 'undefined') {
398
+ return;
399
+ }
400
+ const handleResize = () => {
401
+ setSize({
402
+ width: window.innerWidth,
403
+ height: window.innerHeight,
404
+ });
405
+ };
406
+ // Set initial size
407
+ handleResize();
408
+ // Add event listener
409
+ window.addEventListener('resize', handleResize);
410
+ // Cleanup
411
+ return () => {
412
+ window.removeEventListener('resize', handleResize);
413
+ };
414
+ }, []);
415
+ return size;
416
+ }
417
+
418
+ const ConfettiWrapper = ({ show }) => {
419
+ const { width, height } = useWindowSize();
420
+ if (!show)
421
+ return null;
422
+ return (React.createElement(Confetti, { width: width, height: height, numberOfPieces: 200, recycle: false, style: {
423
+ position: 'fixed',
424
+ top: 0,
425
+ left: 0,
426
+ zIndex: 1001,
427
+ pointerEvents: 'none',
428
+ } }));
429
+ };
430
+
431
+ /**
432
+ * Built-in notification component
433
+ * Modern, theme-aware achievement notification with smooth animations
434
+ */
435
+ const BuiltInNotification = ({ achievement, onClose, duration = 5000, position = 'top-center', theme = 'modern', icons = {}, }) => {
436
+ var _a, _b, _c;
437
+ const [isVisible, setIsVisible] = useState(false);
438
+ const [isExiting, setIsExiting] = useState(false);
439
+ // Merge custom icons with defaults
440
+ const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
441
+ // Get theme configuration
442
+ const themeConfig = getTheme(theme) || builtInThemes.modern;
443
+ const { notification: themeStyles } = themeConfig;
444
+ useEffect(() => {
445
+ // Slide in animation
446
+ const showTimer = setTimeout(() => setIsVisible(true), 10);
447
+ // Auto-dismiss
448
+ const dismissTimer = setTimeout(() => {
449
+ setIsExiting(true);
450
+ setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
451
+ }, duration);
452
+ return () => {
453
+ clearTimeout(showTimer);
454
+ clearTimeout(dismissTimer);
455
+ };
456
+ }, [duration, onClose]);
457
+ const getPositionStyles = () => {
458
+ const base = {
459
+ position: 'fixed',
460
+ zIndex: 9999,
461
+ };
462
+ switch (position) {
463
+ case 'top-center':
464
+ return Object.assign(Object.assign({}, base), { top: 20, left: '50%', transform: 'translateX(-50%)' });
465
+ case 'top-left':
466
+ return Object.assign(Object.assign({}, base), { top: 20, left: 20 });
467
+ case 'top-right':
468
+ return Object.assign(Object.assign({}, base), { top: 20, right: 20 });
469
+ case 'bottom-center':
470
+ return Object.assign(Object.assign({}, base), { bottom: 20, left: '50%', transform: 'translateX(-50%)' });
471
+ case 'bottom-left':
472
+ return Object.assign(Object.assign({}, base), { bottom: 20, left: 20 });
473
+ case 'bottom-right':
474
+ return Object.assign(Object.assign({}, base), { bottom: 20, right: 20 });
475
+ default:
476
+ return Object.assign(Object.assign({}, base), { top: 20, left: '50%', transform: 'translateX(-50%)' });
477
+ }
478
+ };
479
+ 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')
480
+ ? `translateY(${isVisible && !isExiting ? '0' : '-20px'}) ${position.includes('center') ? 'translateX(-50%)' : ''}`
481
+ : `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' });
482
+ const iconStyles = {
483
+ fontSize: '48px',
484
+ lineHeight: 1,
485
+ flexShrink: 0,
486
+ };
487
+ const contentStyles = {
488
+ flex: 1,
489
+ color: themeStyles.textColor,
490
+ minWidth: 0,
491
+ };
492
+ const headerStyles = {
493
+ fontSize: ((_a = themeStyles.fontSize) === null || _a === void 0 ? void 0 : _a.header) || '12px',
494
+ textTransform: 'uppercase',
495
+ letterSpacing: '1px',
496
+ opacity: 0.8,
497
+ marginBottom: '4px',
498
+ color: themeStyles.accentColor,
499
+ fontWeight: 600,
500
+ };
501
+ const titleStyles = {
502
+ fontSize: ((_b = themeStyles.fontSize) === null || _b === void 0 ? void 0 : _b.title) || '18px',
503
+ fontWeight: 'bold',
504
+ marginBottom: '4px',
505
+ overflow: 'hidden',
506
+ textOverflow: 'ellipsis',
507
+ whiteSpace: 'nowrap',
508
+ };
509
+ const descriptionStyles = {
510
+ fontSize: ((_c = themeStyles.fontSize) === null || _c === void 0 ? void 0 : _c.description) || '14px',
511
+ opacity: 0.9,
512
+ overflow: 'hidden',
513
+ textOverflow: 'ellipsis',
514
+ display: '-webkit-box',
515
+ WebkitLineClamp: 2,
516
+ WebkitBoxOrient: 'vertical',
517
+ };
518
+ const closeButtonStyles = {
519
+ background: 'none',
520
+ border: 'none',
521
+ color: themeStyles.textColor,
522
+ fontSize: '24px',
523
+ cursor: 'pointer',
524
+ opacity: 0.6,
525
+ transition: 'opacity 0.2s',
526
+ padding: '4px',
527
+ flexShrink: 0,
528
+ lineHeight: 1,
529
+ };
530
+ // If achievementIconKey exists but not in mergedIcons, use it directly (might be an emoji)
531
+ // Otherwise, look up in mergedIcons or fall back to default
532
+ const icon = (achievement.achievementIconKey &&
533
+ mergedIcons[achievement.achievementIconKey]) ||
534
+ achievement.achievementIconKey ||
535
+ mergedIcons.default ||
536
+ '⭐';
537
+ return (React.createElement("div", { style: containerStyles, "data-testid": "built-in-notification" },
538
+ React.createElement("div", { style: iconStyles }, icon),
539
+ React.createElement("div", { style: contentStyles },
540
+ React.createElement("div", { style: headerStyles }, "Achievement Unlocked!"),
541
+ React.createElement("div", { style: titleStyles }, achievement.achievementTitle),
542
+ achievement.achievementDescription && (React.createElement("div", { style: descriptionStyles }, achievement.achievementDescription))),
543
+ React.createElement("button", { onClick: () => {
544
+ setIsExiting(true);
545
+ setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
546
+ }, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close notification" }, "\u00D7")));
547
+ };
548
+
549
+ /**
550
+ * Built-in confetti component
551
+ * Lightweight CSS-based confetti animation
552
+ */
553
+ const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'], }) => {
554
+ const [isVisible, setIsVisible] = useState(false);
555
+ const { width, height } = useWindowSize();
556
+ useEffect(() => {
557
+ if (show) {
558
+ setIsVisible(true);
559
+ const timer = setTimeout(() => setIsVisible(false), duration);
560
+ return () => clearTimeout(timer);
561
+ }
562
+ else {
563
+ setIsVisible(false);
564
+ }
565
+ }, [show, duration]);
566
+ if (!isVisible)
567
+ return null;
568
+ const containerStyles = {
569
+ position: 'fixed',
570
+ top: 0,
571
+ left: 0,
572
+ width: '100%',
573
+ height: '100%',
574
+ pointerEvents: 'none',
575
+ zIndex: 10001,
576
+ overflow: 'hidden',
577
+ };
578
+ // Generate particles
579
+ const particles = Array.from({ length: particleCount }, (_, i) => {
580
+ const color = colors[Math.floor(Math.random() * colors.length)];
581
+ const startX = Math.random() * width;
582
+ const rotation = Math.random() * 360;
583
+ const fallDuration = 3 + Math.random() * 2; // 3-5 seconds
584
+ const delay = Math.random() * 0.5; // 0-0.5s delay
585
+ const shape = Math.random() > 0.5 ? 'circle' : 'square';
586
+ const particleStyles = {
587
+ position: 'absolute',
588
+ top: '-20px',
589
+ left: `${startX}px`,
590
+ width: '10px',
591
+ height: '10px',
592
+ backgroundColor: color,
593
+ borderRadius: shape === 'circle' ? '50%' : '0',
594
+ transform: `rotate(${rotation}deg)`,
595
+ animation: `confettiFall ${fallDuration}s linear ${delay}s forwards`,
596
+ opacity: 0.9,
597
+ };
598
+ return React.createElement("div", { key: i, style: particleStyles, "data-testid": "confetti-particle" });
599
+ });
600
+ return (React.createElement(React.Fragment, null,
601
+ React.createElement("style", null, `
602
+ @keyframes confettiFall {
603
+ 0% {
604
+ transform: translateY(0) rotate(0deg);
605
+ opacity: 1;
606
+ }
607
+ 100% {
608
+ transform: translateY(${height + 50}px) rotate(720deg);
609
+ opacity: 0;
610
+ }
611
+ }
612
+ `),
613
+ React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
614
+ };
615
+
616
+ /******************************************************************************
617
+ Copyright (c) Microsoft Corporation.
618
+
619
+ Permission to use, copy, modify, and/or distribute this software for any
620
+ purpose with or without fee is hereby granted.
621
+
622
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
623
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
624
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
625
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
626
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
627
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
628
+ PERFORMANCE OF THIS SOFTWARE.
629
+ ***************************************************************************** */
630
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
631
+
632
+
633
+ function __awaiter(thisArg, _arguments, P, generator) {
634
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
635
+ return new (P || (P = Promise))(function (resolve, reject) {
636
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
637
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
638
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
639
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
640
+ });
641
+ }
642
+
643
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
644
+ var e = new Error(message);
645
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
646
+ };
647
+
648
+ /**
649
+ * Legacy UI library detection system
650
+ * Attempts to dynamically import external UI libraries
651
+ * Shows deprecation warnings when detected
652
+ */
653
+ let cachedLibraries = null;
654
+ let detectionAttempted = false;
655
+ let deprecationWarningShown = false;
656
+ /**
657
+ * Attempts to dynamically import legacy UI libraries
658
+ * Uses try/catch to gracefully handle missing dependencies
659
+ * Caches result to avoid multiple import attempts
660
+ *
661
+ * @returns Promise resolving to LegacyLibraries object
662
+ */
663
+ function detectLegacyLibraries() {
664
+ return __awaiter(this, void 0, void 0, function* () {
665
+ if (detectionAttempted && cachedLibraries !== null) {
666
+ return cachedLibraries;
667
+ }
668
+ detectionAttempted = true;
669
+ const libraries = {};
670
+ // Try to import react-toastify
671
+ try {
672
+ const toastifyModule = yield import('react-toastify');
673
+ libraries.toast = toastifyModule.toast;
674
+ libraries.ToastContainer = toastifyModule.ToastContainer;
675
+ }
676
+ catch (_a) {
677
+ // Not installed, will use built-in notification
678
+ }
679
+ // Try to import react-modal
680
+ try {
681
+ const modalModule = yield import('react-modal');
682
+ libraries.Modal = modalModule.default;
683
+ }
684
+ catch (_b) {
685
+ // Not installed, will use built-in modal
686
+ }
687
+ // Try to import react-confetti
688
+ try {
689
+ const confettiModule = yield import('react-confetti');
690
+ libraries.Confetti = confettiModule.default;
691
+ }
692
+ catch (_c) {
693
+ // Not installed, will use built-in confetti
694
+ }
695
+ // Try to import react-use (only for useWindowSize)
696
+ try {
697
+ const reactUseModule = yield import('react-use');
698
+ libraries.useWindowSize = reactUseModule.useWindowSize;
699
+ }
700
+ catch (_d) {
701
+ // Not installed, will use built-in useWindowSize
702
+ }
703
+ cachedLibraries = libraries;
704
+ // Show deprecation warning if ANY legacy library is found
705
+ if (!deprecationWarningShown && Object.keys(libraries).length > 0) {
706
+ const foundLibraries = [];
707
+ if (libraries.toast)
708
+ foundLibraries.push('react-toastify');
709
+ if (libraries.Modal)
710
+ foundLibraries.push('react-modal');
711
+ if (libraries.Confetti)
712
+ foundLibraries.push('react-confetti');
713
+ if (libraries.useWindowSize)
714
+ foundLibraries.push('react-use');
715
+ console.warn(`[react-achievements] DEPRECATION WARNING: External UI dependencies (${foundLibraries.join(', ')}) are deprecated and will become fully optional in v4.0.0.\n\n` +
716
+ `The library now includes built-in UI components with modern design and theme support.\n\n` +
717
+ `To migrate:\n` +
718
+ `1. Add "useBuiltInUI={true}" to your AchievementProvider\n` +
719
+ `2. Test your application (UI will change to modern theme)\n` +
720
+ `3. Optionally customize with theme="minimal" or theme="gamified"\n` +
721
+ `4. Remove external dependencies from package.json\n\n` +
722
+ `To silence this warning, set useBuiltInUI={true} in AchievementProvider.\n\n` +
723
+ `Learn more: https://github.com/dave-b-b/react-achievements#migration-guide`);
724
+ deprecationWarningShown = true;
725
+ }
726
+ return libraries;
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Built-in modal component
732
+ * Modern, theme-aware achievement modal with smooth animations
733
+ */
734
+ const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'modern', }) => {
735
+ // Merge custom icons with defaults
736
+ const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
737
+ // Get theme configuration
738
+ const themeConfig = getTheme(theme) || builtInThemes.modern;
739
+ const { modal: themeStyles } = themeConfig;
740
+ useEffect(() => {
741
+ if (isOpen) {
742
+ // Lock body scroll when modal is open
743
+ document.body.style.overflow = 'hidden';
744
+ }
745
+ else {
746
+ // Restore body scroll
747
+ document.body.style.overflow = '';
748
+ }
749
+ return () => {
750
+ document.body.style.overflow = '';
751
+ };
752
+ }, [isOpen]);
753
+ if (!isOpen)
754
+ return null;
755
+ const overlayStyles = {
756
+ position: 'fixed',
757
+ top: 0,
758
+ left: 0,
759
+ right: 0,
760
+ bottom: 0,
761
+ backgroundColor: themeStyles.overlayColor,
762
+ display: 'flex',
763
+ alignItems: 'center',
764
+ justifyContent: 'center',
765
+ zIndex: 10000,
766
+ animation: 'fadeIn 0.3s ease-in-out',
767
+ };
768
+ const modalStyles = {
769
+ background: themeStyles.background,
770
+ borderRadius: themeStyles.borderRadius,
771
+ padding: '32px',
772
+ maxWidth: '600px',
773
+ width: '90%',
774
+ maxHeight: '80vh',
775
+ overflow: 'auto',
776
+ boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
777
+ animation: 'scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
778
+ position: 'relative',
779
+ };
780
+ const headerStyles = {
781
+ display: 'flex',
782
+ justifyContent: 'space-between',
783
+ alignItems: 'center',
784
+ marginBottom: '24px',
785
+ };
786
+ const titleStyles = {
787
+ margin: 0,
788
+ color: themeStyles.textColor,
789
+ fontSize: themeStyles.headerFontSize || '28px',
790
+ fontWeight: 'bold',
791
+ };
792
+ const closeButtonStyles = {
793
+ background: 'none',
794
+ border: 'none',
795
+ fontSize: '32px',
796
+ cursor: 'pointer',
797
+ color: themeStyles.textColor,
798
+ opacity: 0.6,
799
+ transition: 'opacity 0.2s',
800
+ padding: 0,
801
+ lineHeight: 1,
802
+ };
803
+ const isBadgeLayout = themeStyles.achievementLayout === 'badge';
804
+ const listStyles = isBadgeLayout
805
+ ? {
806
+ display: 'grid',
807
+ gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
808
+ gap: '16px',
809
+ }
810
+ : {
811
+ display: 'flex',
812
+ flexDirection: 'column',
813
+ gap: '12px',
814
+ };
815
+ const getAchievementItemStyles = (isUnlocked) => {
816
+ const baseStyles = {
817
+ borderRadius: themeStyles.achievementCardBorderRadius || '12px',
818
+ backgroundColor: isUnlocked
819
+ ? `${themeStyles.accentColor}1A` // 10% opacity
820
+ : 'rgba(255, 255, 255, 0.05)',
821
+ border: `2px solid ${isUnlocked ? themeStyles.accentColor : 'rgba(255, 255, 255, 0.1)'}`,
822
+ opacity: isUnlocked ? 1 : 0.5,
823
+ transition: 'all 0.2s',
824
+ };
825
+ if (isBadgeLayout) {
826
+ // Badge layout: vertical, centered, square-ish
827
+ 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' });
828
+ }
829
+ else {
830
+ // Horizontal layout (default)
831
+ return Object.assign(Object.assign({}, baseStyles), { display: 'flex', gap: '16px', padding: '16px' });
832
+ }
833
+ };
834
+ const getIconContainerStyles = (isUnlocked) => {
835
+ if (isBadgeLayout) {
836
+ return {
837
+ fontSize: '48px',
838
+ lineHeight: 1,
839
+ marginBottom: '8px',
840
+ opacity: isUnlocked ? 1 : 0.3,
841
+ };
842
+ }
843
+ return {
844
+ fontSize: '40px',
845
+ flexShrink: 0,
846
+ lineHeight: 1,
847
+ opacity: isUnlocked ? 1 : 0.3,
848
+ };
849
+ };
850
+ const contentStyles = isBadgeLayout
851
+ ? {
852
+ width: '100%',
853
+ }
854
+ : {
855
+ flex: 1,
856
+ minWidth: 0,
857
+ };
858
+ const achievementTitleStyles = isBadgeLayout
859
+ ? {
860
+ margin: '0 0 4px 0',
861
+ color: themeStyles.textColor,
862
+ fontSize: '14px',
863
+ fontWeight: 'bold',
864
+ lineHeight: '1.3',
865
+ }
866
+ : {
867
+ margin: '0 0 8px 0',
868
+ color: themeStyles.textColor,
869
+ fontSize: '18px',
870
+ fontWeight: 'bold',
871
+ overflow: 'hidden',
872
+ textOverflow: 'ellipsis',
873
+ whiteSpace: 'nowrap',
874
+ };
875
+ const achievementDescriptionStyles = isBadgeLayout
876
+ ? {
877
+ margin: 0,
878
+ color: themeStyles.textColor,
879
+ opacity: 0.7,
880
+ fontSize: '11px',
881
+ lineHeight: '1.3',
882
+ }
883
+ : {
884
+ margin: 0,
885
+ color: themeStyles.textColor,
886
+ opacity: 0.8,
887
+ fontSize: '14px',
888
+ };
889
+ const getLockIconStyles = () => {
890
+ if (isBadgeLayout) {
891
+ return {
892
+ position: 'absolute',
893
+ top: '8px',
894
+ right: '8px',
895
+ fontSize: '18px',
896
+ opacity: 0.6,
897
+ };
898
+ }
899
+ return {
900
+ fontSize: '24px',
901
+ flexShrink: 0,
902
+ opacity: 0.5,
903
+ };
904
+ };
905
+ return (React.createElement(React.Fragment, null,
906
+ React.createElement("style", null, `
907
+ @keyframes fadeIn {
908
+ from { opacity: 0; }
909
+ to { opacity: 1; }
910
+ }
911
+ @keyframes scaleIn {
912
+ from {
913
+ transform: scale(0.9);
914
+ opacity: 0;
915
+ }
916
+ to {
917
+ transform: scale(1);
918
+ opacity: 1;
919
+ }
920
+ }
921
+ `),
922
+ React.createElement("div", { style: overlayStyles, onClick: onClose, "data-testid": "built-in-modal-overlay" },
923
+ React.createElement("div", { style: modalStyles, onClick: (e) => e.stopPropagation(), "data-testid": "built-in-modal" },
924
+ React.createElement("div", { style: headerStyles },
925
+ React.createElement("h2", { style: titleStyles }, "\uD83C\uDFC6 Achievements"),
926
+ 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")),
927
+ 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) => {
928
+ // If achievementIconKey exists but not in mergedIcons, use it directly (might be an emoji)
929
+ // Otherwise, look up in mergedIcons or fall back to default
930
+ const icon = (achievement.achievementIconKey &&
931
+ mergedIcons[achievement.achievementIconKey]) ||
932
+ achievement.achievementIconKey ||
933
+ mergedIcons.default ||
934
+ '⭐';
935
+ return (React.createElement("div", { key: achievement.achievementId, style: Object.assign(Object.assign({}, getAchievementItemStyles(achievement.isUnlocked)), { position: isBadgeLayout ? 'relative' : 'static' }) },
936
+ React.createElement("div", { style: getIconContainerStyles(achievement.isUnlocked) }, icon),
937
+ React.createElement("div", { style: contentStyles },
938
+ React.createElement("h3", { style: achievementTitleStyles }, achievement.achievementTitle),
939
+ React.createElement("p", { style: achievementDescriptionStyles }, achievement.achievementDescription)),
940
+ !achievement.isUnlocked && (React.createElement("div", { style: getLockIconStyles() }, "\uD83D\uDD12"))));
941
+ })))))));
942
+ };
943
+
944
+ /**
945
+ * Legacy library wrappers for backwards compatibility
946
+ * Wraps external UI libraries to match our component interfaces
947
+ */
948
+ /**
949
+ * Wrapper for react-toastify toast notifications
950
+ * Falls back to built-in notification if not available
951
+ */
952
+ const createLegacyToastNotification = (libraries) => {
953
+ return ({ achievement, onClose, icons = {} }) => {
954
+ const { toast } = libraries;
955
+ // Merge custom icons with defaults from react-achievements
956
+ const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
957
+ const icon = (achievement.achievementIconKey &&
958
+ mergedIcons[achievement.achievementIconKey]) ||
959
+ achievement.achievementIconKey ||
960
+ mergedIcons.default ||
961
+ '⭐';
962
+ useEffect(() => {
963
+ if (!toast)
964
+ return;
965
+ // Call toast.success with achievement content
966
+ toast.success(React.createElement("div", { style: { display: 'flex', alignItems: 'center' } },
967
+ React.createElement("span", { style: { fontSize: '2em', marginRight: '10px' } }, icon),
968
+ React.createElement("div", null,
969
+ React.createElement("div", { style: { fontSize: '12px', opacity: 0.8, marginBottom: '4px' } }, "Achievement Unlocked!"),
970
+ React.createElement("div", { style: { fontWeight: 'bold', marginBottom: '4px' } }, achievement.achievementTitle),
971
+ achievement.achievementDescription && (React.createElement("div", { style: { fontSize: '13px', opacity: 0.9 } }, achievement.achievementDescription)))), {
972
+ position: 'top-right',
973
+ autoClose: 5000,
974
+ hideProgressBar: false,
975
+ closeOnClick: true,
976
+ pauseOnHover: true,
977
+ draggable: true,
978
+ toastId: achievement.achievementId,
979
+ onClose,
980
+ });
981
+ }, [achievement, toast, onClose, icon]);
982
+ return null; // Toast handles its own rendering
983
+ };
984
+ };
985
+ /**
986
+ * Wrapper for react-confetti Confetti component
987
+ * Falls back to built-in confetti if not available
988
+ */
989
+ const createLegacyConfettiWrapper = (libraries) => {
990
+ return ({ show, duration = 5000, particleCount = 200, colors }) => {
991
+ const { Confetti, useWindowSize: legacyUseWindowSize } = libraries;
992
+ // If Confetti not available, use built-in
993
+ if (!Confetti) {
994
+ return (React.createElement(BuiltInConfetti, { show: show, duration: duration, particleCount: particleCount, colors: colors }));
995
+ }
996
+ // Use react-confetti with react-use's useWindowSize if available
997
+ // Otherwise fall back to default dimensions
998
+ let width = 0;
999
+ let height = 0;
1000
+ if (legacyUseWindowSize) {
1001
+ const size = legacyUseWindowSize();
1002
+ width = size.width;
1003
+ height = size.height;
1004
+ }
1005
+ else if (typeof window !== 'undefined') {
1006
+ width = window.innerWidth;
1007
+ height = window.innerHeight;
1008
+ }
1009
+ if (!show)
1010
+ return null;
1011
+ return (React.createElement(Confetti, { width: width, height: height, numberOfPieces: particleCount, recycle: false, colors: colors, style: {
1012
+ position: 'fixed',
1013
+ top: 0,
1014
+ left: 0,
1015
+ zIndex: 10001,
1016
+ pointerEvents: 'none',
1017
+ } }));
1018
+ };
1019
+ };
1020
+
1021
+ const AchievementContext = createContext(undefined);
1022
+ const AchievementProvider = ({ achievements: achievementsConfig, storage = 'local', children, icons = {}, onError, restApiConfig, ui = {}, useBuiltInUI = false, engine: externalEngine, eventMapping, }) => {
1023
+ // VALIDATION: Prevent mixing patterns
1024
+ if (achievementsConfig && externalEngine) {
1025
+ throw new Error('Cannot provide both "achievements" and "engine" props to AchievementProvider.\n\n' +
1026
+ 'Choose one pattern:\n' +
1027
+ '1. OLD metric-based: <AchievementProvider achievements={config}>\n' +
1028
+ '2. NEW event-based: <AchievementProvider engine={myEngine}>');
1029
+ }
1030
+ // Track which pattern is being used
1031
+ const isLegacyPattern = Boolean(achievementsConfig);
1032
+ // Engine instance (either external or auto-created)
1033
+ // Initialize synchronously BEFORE first render to avoid timing issues
1034
+ const [engine] = useState(() => {
1035
+ if (externalEngine) {
1036
+ // NEW PATTERN: Use external injected engine
1037
+ return externalEngine;
1038
+ }
1039
+ // OLD PATTERN: Auto-create engine from achievements
1040
+ if (!achievementsConfig) {
1041
+ throw new Error('AchievementProvider requires either "achievements" or "engine" prop.\n\n' +
1042
+ '1. OLD pattern: <AchievementProvider achievements={config}>\n' +
1043
+ '2. NEW pattern: <AchievementProvider engine={myEngine}>');
1044
+ }
1045
+ return new AchievementEngine({
1046
+ achievements: achievementsConfig,
1047
+ storage: storage, // Type cast needed for compatibility
1048
+ restApiConfig,
1049
+ onError: onError,
1050
+ eventMapping,
1051
+ });
1052
+ });
1053
+ // React state synced from engine
1054
+ // Initialize with current engine state (engine is available immediately)
1055
+ const [achievementState, setAchievementState] = useState(() => {
1056
+ const unlocked = engine.getUnlocked();
1057
+ return {
1058
+ unlocked: [...unlocked],
1059
+ all: {}, // Will be populated by getAllAchievements
1060
+ };
1061
+ });
1062
+ // Track which achievements have been seen to avoid duplicate notifications
1063
+ const seenAchievementsRef = useRef(new Set(engine.getUnlocked()));
1064
+ const [showConfetti, setShowConfetti] = useState(false);
1065
+ // NEW: UI component resolution state (v3.6.0)
1066
+ const [legacyLibraries, setLegacyLibraries] = useState(null);
1067
+ const [uiReady, setUiReady] = useState(useBuiltInUI);
1068
+ const [currentNotification, setCurrentNotification] = useState(null);
1069
+ // Cleanup: Destroy engine on unmount (only if we auto-created it)
1070
+ useEffect(() => {
1071
+ return () => {
1072
+ // Only destroy if we auto-created the engine
1073
+ if (!externalEngine) {
1074
+ engine.destroy();
1075
+ }
1076
+ };
1077
+ }, [engine, externalEngine]);
1078
+ // Subscribe to engine events
1079
+ useEffect(() => {
1080
+ // Engine is always available (initialized synchronously)
1081
+ // Handle achievement unlocked
1082
+ const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
1083
+ // Update unlocked list
1084
+ setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: [...engine.getUnlocked()] })));
1085
+ // Show notification if not seen before
1086
+ if (!seenAchievementsRef.current.has(event.achievementId)) {
1087
+ seenAchievementsRef.current.add(event.achievementId);
1088
+ if (ui.enableNotifications !== false) {
1089
+ // Create a full AchievementWithStatus object for the notification
1090
+ const unlockedAchievement = {
1091
+ achievementId: event.achievementId,
1092
+ achievementTitle: event.achievementTitle,
1093
+ achievementDescription: event.achievementDescription,
1094
+ achievementIconKey: event.achievementIconKey,
1095
+ isUnlocked: true,
1096
+ };
1097
+ setCurrentNotification(unlockedAchievement);
1098
+ // Show confetti
1099
+ setShowConfetti(true);
1100
+ // Hide confetti after 5 seconds
1101
+ setTimeout(() => {
1102
+ setShowConfetti(false);
1103
+ }, 5000);
1104
+ }
1105
+ }
1106
+ });
1107
+ // Handle state changes (for syncing unlocked list)
1108
+ const unsubscribeStateChanged = engine.on('state:changed', () => {
1109
+ setAchievementState(prev => (Object.assign(Object.assign({}, prev), { unlocked: [...engine.getUnlocked()] })));
1110
+ });
1111
+ return () => {
1112
+ unsubscribeUnlocked();
1113
+ unsubscribeStateChanged();
1114
+ };
1115
+ }, [engine, icons, ui.enableNotifications]);
1116
+ // Detect legacy UI libraries on mount
1117
+ useEffect(() => {
1118
+ if (useBuiltInUI) {
1119
+ setUiReady(true);
1120
+ return;
1121
+ }
1122
+ detectLegacyLibraries().then((libs) => {
1123
+ setLegacyLibraries(libs);
1124
+ setUiReady(true);
1125
+ });
1126
+ }, [useBuiltInUI]);
1127
+ // Resolve UI components based on detection and config
1128
+ const NotificationComponent = ui.NotificationComponent ||
1129
+ (useBuiltInUI ? BuiltInNotification :
1130
+ legacyLibraries && Object.keys(legacyLibraries).length > 0 && legacyLibraries.toast
1131
+ ? createLegacyToastNotification(legacyLibraries)
1132
+ : BuiltInNotification);
1133
+ const ConfettiComponentResolved = ui.ConfettiComponent ||
1134
+ (useBuiltInUI ? BuiltInConfetti :
1135
+ legacyLibraries && Object.keys(legacyLibraries).length > 0 && legacyLibraries.Confetti
1136
+ ? createLegacyConfettiWrapper(legacyLibraries)
1137
+ : BuiltInConfetti);
1138
+ // Context methods - delegate to engine
1139
+ const update = (newMetrics) => {
1140
+ engine.update(newMetrics);
1141
+ };
1142
+ const reset = () => {
1143
+ engine.reset();
1144
+ seenAchievementsRef.current.clear();
1145
+ setShowConfetti(false);
1146
+ setCurrentNotification(null);
1147
+ };
1148
+ const getState = () => {
1149
+ const metrics = engine.getMetrics();
1150
+ const unlocked = engine.getUnlocked();
1151
+ // Convert metrics to array format for backward compatibility
1152
+ const metricsInArrayFormat = {};
1153
+ Object.entries(metrics).forEach(([key, value]) => {
1154
+ metricsInArrayFormat[key] = Array.isArray(value) ? value : [value];
1155
+ });
1156
+ return {
1157
+ metrics: metricsInArrayFormat,
1158
+ unlocked: [...unlocked],
1159
+ };
1160
+ };
1161
+ const exportData = () => {
1162
+ return engine.export();
1163
+ };
1164
+ const importData = (jsonString, options) => {
1165
+ var _a;
1166
+ const result = engine.import(jsonString, options);
1167
+ // Update seen achievements to prevent duplicate notifications
1168
+ if ((result === null || result === void 0 ? void 0 : result.success) && 'mergedUnlocked' in result) {
1169
+ (_a = result.mergedUnlocked) === null || _a === void 0 ? void 0 : _a.forEach((id) => {
1170
+ seenAchievementsRef.current.add(id);
1171
+ });
1172
+ }
1173
+ return result;
1174
+ };
1175
+ const getAllAchievements = () => {
1176
+ return engine.getAllAchievements();
1177
+ };
1178
+ return (React.createElement(AchievementContext.Provider, { value: {
1179
+ update,
1180
+ achievements: achievementState,
1181
+ reset,
1182
+ getState,
1183
+ exportData,
1184
+ importData,
1185
+ getAllAchievements,
1186
+ engine, // Always defined - no undefined fallback needed
1187
+ _isLegacyPattern: isLegacyPattern, // NEW: Expose pattern flag to hooks
1188
+ } },
1189
+ children,
1190
+ uiReady && currentNotification && ui.enableNotifications !== false && (React.createElement(NotificationComponent, { achievement: currentNotification, onClose: () => setCurrentNotification(null), duration: 5000, position: ui.notificationPosition || 'top-center', theme: ui.theme || 'modern', icons: icons })),
1191
+ uiReady && ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: 5000 }))));
1192
+ };
1193
+
1194
+ const useAchievements = () => {
1195
+ const context = useContext(AchievementContext);
1196
+ if (!context) {
1197
+ throw new Error('useAchievements must be used within an AchievementProvider');
1198
+ }
1199
+ // STRICT CHECK: Detect if Provider has injected engine (new pattern)
1200
+ if (!context._isLegacyPattern) {
1201
+ throw new Error('Cannot use useAchievements when AchievementProvider has injected engine.\n\n' +
1202
+ 'You are using the NEW event-based pattern.\n' +
1203
+ 'useAchievements is for the OLD metric-based pattern only.\n\n' +
1204
+ 'Use engine.emit() instead:\n' +
1205
+ ' const engine = useAchievementEngine();\n' +
1206
+ ' engine.emit("eventName", data);\n\n' +
1207
+ 'Or switch to the old pattern:\n' +
1208
+ ' <AchievementProvider achievements={config}>\n' +
1209
+ ' const { track } = useAchievements();\n' +
1210
+ ' track("score", 100);');
1211
+ }
1212
+ return context;
1213
+ };
1214
+
1215
+ /**
1216
+ * A simplified hook for achievement tracking.
1217
+ * Provides an easier API for common use cases while maintaining access to advanced features.
1218
+ */
1219
+ const useSimpleAchievements = () => {
1220
+ const { update, achievements, reset, getState, exportData, importData, getAllAchievements } = useAchievements();
1221
+ return {
1222
+ /**
1223
+ * Track a metric value for achievements
1224
+ * @param metric - The metric name (e.g., 'score', 'level')
1225
+ * @param value - The metric value
1226
+ */
1227
+ track: (metric, value) => update({ [metric]: value }),
1228
+ /**
1229
+ * Increment a numeric metric by a specified amount
1230
+ * @param metric - The metric name (e.g., 'buttonClicks', 'score')
1231
+ * @param amount - The amount to increment by (defaults to 1)
1232
+ */
1233
+ increment: (metric, amount = 1) => {
1234
+ const currentState = getState();
1235
+ const currentMetricArray = currentState.metrics[metric] || [0];
1236
+ const currentValue = Array.isArray(currentMetricArray) ? currentMetricArray[0] : currentMetricArray;
1237
+ const newValue = (typeof currentValue === 'number' ? currentValue : 0) + amount;
1238
+ update({ [metric]: newValue });
1239
+ },
1240
+ /**
1241
+ * Track multiple metrics at once
1242
+ * @param metrics - Object with metric names as keys and values
1243
+ */
1244
+ trackMultiple: (metrics) => update(metrics),
1245
+ /**
1246
+ * Array of unlocked achievement IDs
1247
+ */
1248
+ unlocked: achievements.unlocked,
1249
+ /**
1250
+ * All available achievements
1251
+ */
1252
+ all: achievements.all,
1253
+ /**
1254
+ * Number of unlocked achievements
1255
+ */
1256
+ unlockedCount: achievements.unlocked.length,
1257
+ /**
1258
+ * Reset all achievement progress
1259
+ */
1260
+ reset,
1261
+ /**
1262
+ * Get current state (advanced usage)
1263
+ */
1264
+ getState,
1265
+ /**
1266
+ * Export achievement data to JSON string
1267
+ * @returns JSON string containing all achievement data
1268
+ */
1269
+ exportData,
1270
+ /**
1271
+ * Import achievement data from JSON string
1272
+ * @param jsonString - JSON string containing exported achievement data
1273
+ * @param options - Import options (merge strategy, validation)
1274
+ * @returns Import result with success status and any errors
1275
+ */
1276
+ importData,
1277
+ /**
1278
+ * Get all achievements with their unlock status
1279
+ * @returns Array of achievements with isUnlocked boolean property
1280
+ */
1281
+ getAllAchievements,
1282
+ };
1283
+ };
1284
+
1285
+ /**
1286
+ * Hook to access the injected AchievementEngine instance (NEW event-based pattern)
1287
+ *
1288
+ * IMPORTANT: This hook only works when Provider has an externally created engine injected via the `engine` prop.
1289
+ * Do NOT use this hook with the old `achievements` prop pattern - use `useAchievements()` instead.
1290
+ *
1291
+ * @example
1292
+ * ```tsx
1293
+ * // Create engine outside React
1294
+ * import { AchievementEngine } from 'achievements-engine';
1295
+ *
1296
+ * const myEngine = new AchievementEngine({
1297
+ * achievements: config,
1298
+ * eventMapping: { 'userScored': 'score', 'levelUp': 'level' },
1299
+ * storage: 'local'
1300
+ * });
1301
+ *
1302
+ * // Inject into Provider
1303
+ * <AchievementProvider engine={myEngine}>
1304
+ * <App />
1305
+ * </AchievementProvider>
1306
+ *
1307
+ * function App() {
1308
+ * const engine = useAchievementEngine();
1309
+ * engine.emit('userScored', 100);
1310
+ * engine.emit('levelUp', 5);
1311
+ * }
1312
+ * ```
1313
+ *
1314
+ * @returns AchievementEngine instance
1315
+ * @throws Error if used with old achievements prop pattern
1316
+ * @throws Error if used outside AchievementProvider
1317
+ * @since 3.8.0
1318
+ */
1319
+ const useAchievementEngine = () => {
1320
+ const context = useContext(AchievementContext);
1321
+ if (!context) {
1322
+ throw new Error('useAchievementEngine must be used within an AchievementProvider.\n\n' +
1323
+ 'Wrap your component tree:\n' +
1324
+ 'const myEngine = new AchievementEngine({ achievements, eventMapping });\n' +
1325
+ '<AchievementProvider engine={myEngine}>\n' +
1326
+ ' <YourComponent />\n' +
1327
+ '</AchievementProvider>');
1328
+ }
1329
+ // STRICT CHECK: Detect if Provider was initialized with achievements (old pattern)
1330
+ if (context._isLegacyPattern) {
1331
+ throw new Error('Cannot use useAchievementEngine when AchievementProvider has achievements prop.\n\n' +
1332
+ 'You are using the OLD metric-based pattern.\n' +
1333
+ 'useAchievementEngine is for the NEW event-based pattern only.\n\n' +
1334
+ 'Choose one:\n\n' +
1335
+ '1. OLD PATTERN (keep current code):\n' +
1336
+ ' <AchievementProvider achievements={config}>\n' +
1337
+ ' const { track } = useAchievements();\n' +
1338
+ ' track("score", 100);\n\n' +
1339
+ '2. NEW PATTERN (migrate to events):\n' +
1340
+ ' const engine = new AchievementEngine({ achievements, eventMapping });\n' +
1341
+ ' <AchievementProvider engine={engine}>\n' +
1342
+ ' const engine = useAchievementEngine();\n' +
1343
+ ' engine.emit("userScored", 100);');
1344
+ }
1345
+ if (!context.engine) {
1346
+ throw new Error('No engine available. AchievementProvider requires either:\n' +
1347
+ '1. achievements prop (old pattern)\n' +
1348
+ '2. engine prop (new pattern)');
1349
+ }
1350
+ return context.engine;
1351
+ };
1352
+
1353
+ export { AchievementContext, AchievementProvider, BadgesButton, BadgesButtonWithModal, BadgesModal, BuiltInConfetti, BuiltInModal, BuiltInNotification, ConfettiWrapper, defaultAchievementIcons, defaultStyles, isAsyncStorage, useAchievementEngine, useAchievements, useSimpleAchievements, useWindowSize };
1354
+ //# sourceMappingURL=index.esm.js.map