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