react-achievements 3.9.3 → 4.1.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/README.md +172 -100
- package/dist/headless.cjs +317 -0
- package/dist/headless.cjs.map +1 -0
- package/dist/headless.d.ts +176 -0
- package/dist/headless.esm.js +222 -0
- package/dist/headless.esm.js.map +1 -0
- package/dist/index.cjs +839 -881
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +163 -153
- package/dist/index.esm.js +835 -883
- package/dist/index.esm.js.map +1 -1
- package/dist/web.cjs +1416 -0
- package/dist/web.cjs.map +1 -0
- package/dist/web.d.ts +534 -0
- package/dist/web.esm.js +1306 -0
- package/dist/web.esm.js.map +1 -0
- package/package.json +13 -28
- package/dist/types/__mocks__/confetti-wrapper.d.ts +0 -5
- package/dist/types/__mocks__/react-confetti.d.ts +0 -3
- package/dist/types/__mocks__/react-toastify.d.ts +0 -13
- package/dist/types/core/components/BadgesButton.d.ts +0 -25
- package/dist/types/core/components/BadgesButtonWithModal.d.ts +0 -53
- package/dist/types/core/components/BadgesModal.d.ts +0 -14
- package/dist/types/core/components/ConfettiWrapper.d.ts +0 -6
- package/dist/types/core/errors/AchievementErrors.d.ts +0 -55
- package/dist/types/core/hooks/useWindowSize.d.ts +0 -16
- package/dist/types/core/icons/defaultIcons.d.ts +0 -8
- package/dist/types/core/storage/AsyncStorageAdapter.d.ts +0 -48
- package/dist/types/core/storage/IndexedDBStorage.d.ts +0 -29
- package/dist/types/core/storage/LocalStorage.d.ts +0 -16
- package/dist/types/core/storage/MemoryStorage.d.ts +0 -11
- package/dist/types/core/storage/OfflineQueueStorage.d.ts +0 -42
- package/dist/types/core/storage/RestApiStorage.d.ts +0 -20
- package/dist/types/core/styles/defaultStyles.d.ts +0 -2
- package/dist/types/core/types.d.ts +0 -115
- package/dist/types/core/ui/BuiltInConfetti.d.ts +0 -7
- package/dist/types/core/ui/BuiltInModal.d.ts +0 -7
- package/dist/types/core/ui/BuiltInNotification.d.ts +0 -7
- package/dist/types/core/ui/LegacyWrappers.d.ts +0 -21
- package/dist/types/core/ui/interfaces.d.ts +0 -127
- package/dist/types/core/ui/legacyDetector.d.ts +0 -40
- package/dist/types/core/ui/themes.d.ts +0 -14
- package/dist/types/core/utils/configNormalizer.d.ts +0 -3
- package/dist/types/core/utils/dataExport.d.ts +0 -34
- package/dist/types/core/utils/dataImport.d.ts +0 -50
- package/dist/types/hooks/useAchievementEngine.d.ts +0 -36
- package/dist/types/hooks/useAchievements.d.ts +0 -1
- package/dist/types/hooks/useSimpleAchievements.d.ts +0 -63
- package/dist/types/index.d.ts +0 -36
- package/dist/types/providers/AchievementProvider.d.ts +0 -47
- package/dist/types/setupTests.d.ts +0 -1
- package/dist/types/utils/achievementHelpers.d.ts +0 -135
package/dist/index.cjs
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
var achievementsEngine = require('achievements-engine');
|
|
4
4
|
var React = require('react');
|
|
5
|
-
var Modal = require('react-modal');
|
|
6
|
-
var Confetti = require('react-confetti');
|
|
7
5
|
|
|
8
6
|
// Type guard to detect async storage
|
|
9
7
|
function isAsyncStorage(storage) {
|
|
@@ -19,6 +17,174 @@ var StorageType;
|
|
|
19
17
|
StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
|
|
20
18
|
})(StorageType || (StorageType = {}));
|
|
21
19
|
|
|
20
|
+
/******************************************************************************
|
|
21
|
+
Copyright (c) Microsoft Corporation.
|
|
22
|
+
|
|
23
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
24
|
+
purpose with or without fee is hereby granted.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
27
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
28
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
29
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
30
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
31
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
32
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
33
|
+
***************************************************************************** */
|
|
34
|
+
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
function __rest(s, e) {
|
|
38
|
+
var t = {};
|
|
39
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
40
|
+
t[p] = s[p];
|
|
41
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
42
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
43
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
44
|
+
t[p[i]] = s[p[i]];
|
|
45
|
+
}
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const warnedMessages = new Set();
|
|
55
|
+
function warnDeprecation(message) {
|
|
56
|
+
var _a, _b;
|
|
57
|
+
const isProduction = typeof globalThis !== 'undefined' &&
|
|
58
|
+
((_b = (_a = globalThis.process) === null || _a === void 0 ? void 0 : _a.env) === null || _b === void 0 ? void 0 : _b.NODE_ENV) === 'production';
|
|
59
|
+
if (isProduction || warnedMessages.has(message)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
warnedMessages.add(message);
|
|
63
|
+
console.warn(`[react-achievements] ${message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const AchievementContext = React.createContext(undefined);
|
|
67
|
+
const getAllAchievementRecord = (engine) => {
|
|
68
|
+
return Object.fromEntries(engine.getAllAchievements().map((achievement) => [
|
|
69
|
+
achievement.achievementId,
|
|
70
|
+
achievement,
|
|
71
|
+
]));
|
|
72
|
+
};
|
|
73
|
+
const AchievementProvider$1 = ({ achievements: achievementsConfig, storage = 'local', children, onError, useBuiltInUI, restApiConfig, engine: externalEngine, eventMapping, icons = {}, }) => {
|
|
74
|
+
if (useBuiltInUI !== undefined) {
|
|
75
|
+
warnDeprecation('`useBuiltInUI` is deprecated and is now a no-op because built-in UI is the default. It will be removed in 4.2.');
|
|
76
|
+
}
|
|
77
|
+
if (achievementsConfig && externalEngine) {
|
|
78
|
+
throw new Error('Cannot provide both "achievements" and "engine" props to AchievementProvider.\n\n' +
|
|
79
|
+
'Choose one pattern:\n' +
|
|
80
|
+
'1. Direct metric tracking: <AchievementProvider achievements={config}>\n' +
|
|
81
|
+
'2. Event-based tracking: <AchievementProvider engine={myEngine}>');
|
|
82
|
+
}
|
|
83
|
+
const isProviderCreatedEngine = Boolean(achievementsConfig);
|
|
84
|
+
const [engine] = React.useState(() => {
|
|
85
|
+
if (externalEngine) {
|
|
86
|
+
return externalEngine;
|
|
87
|
+
}
|
|
88
|
+
if (!achievementsConfig) {
|
|
89
|
+
throw new Error('AchievementProvider requires either "achievements" or "engine" prop.\n\n' +
|
|
90
|
+
'1. Direct metric tracking: <AchievementProvider achievements={config}>\n' +
|
|
91
|
+
'2. Event-based tracking: <AchievementProvider engine={myEngine}>');
|
|
92
|
+
}
|
|
93
|
+
return new achievementsEngine.AchievementEngine({
|
|
94
|
+
achievements: achievementsConfig,
|
|
95
|
+
storage: storage,
|
|
96
|
+
restApiConfig,
|
|
97
|
+
onError: onError,
|
|
98
|
+
eventMapping,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const [achievementState, setAchievementState] = React.useState(() => ({
|
|
102
|
+
unlocked: [...engine.getUnlocked()],
|
|
103
|
+
all: getAllAchievementRecord(engine),
|
|
104
|
+
}));
|
|
105
|
+
const syncAchievementState = React.useCallback(() => {
|
|
106
|
+
setAchievementState({
|
|
107
|
+
unlocked: [...engine.getUnlocked()],
|
|
108
|
+
all: getAllAchievementRecord(engine),
|
|
109
|
+
});
|
|
110
|
+
}, [engine]);
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
return () => {
|
|
113
|
+
if (!externalEngine) {
|
|
114
|
+
engine.destroy();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}, [engine, externalEngine]);
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
const unsubscribeUnlocked = engine.on('achievement:unlocked', syncAchievementState);
|
|
120
|
+
const unsubscribeStateChanged = engine.on('state:changed', syncAchievementState);
|
|
121
|
+
return () => {
|
|
122
|
+
unsubscribeUnlocked();
|
|
123
|
+
unsubscribeStateChanged();
|
|
124
|
+
};
|
|
125
|
+
}, [engine, syncAchievementState]);
|
|
126
|
+
const update = (newMetrics) => {
|
|
127
|
+
engine.update(newMetrics);
|
|
128
|
+
};
|
|
129
|
+
const reset = () => {
|
|
130
|
+
engine.reset();
|
|
131
|
+
syncAchievementState();
|
|
132
|
+
};
|
|
133
|
+
const getState = () => {
|
|
134
|
+
const metrics = engine.getMetrics();
|
|
135
|
+
const unlocked = engine.getUnlocked();
|
|
136
|
+
const metricsInArrayFormat = {};
|
|
137
|
+
Object.entries(metrics).forEach(([key, value]) => {
|
|
138
|
+
metricsInArrayFormat[key] = Array.isArray(value) ? value : [value];
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
metrics: metricsInArrayFormat,
|
|
142
|
+
unlocked: [...unlocked],
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const exportData = () => {
|
|
146
|
+
return engine.export();
|
|
147
|
+
};
|
|
148
|
+
const importData = (jsonString, options) => {
|
|
149
|
+
const result = engine.import(jsonString, options);
|
|
150
|
+
syncAchievementState();
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
const getAllAchievements = () => {
|
|
154
|
+
return engine.getAllAchievements();
|
|
155
|
+
};
|
|
156
|
+
return (React.createElement(AchievementContext.Provider, { value: {
|
|
157
|
+
update,
|
|
158
|
+
achievements: achievementState,
|
|
159
|
+
reset,
|
|
160
|
+
getState,
|
|
161
|
+
exportData,
|
|
162
|
+
importData,
|
|
163
|
+
getAllAchievements,
|
|
164
|
+
engine,
|
|
165
|
+
icons,
|
|
166
|
+
_isLegacyPattern: isProviderCreatedEngine,
|
|
167
|
+
} }, children));
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Access the active AchievementEngine instance.
|
|
172
|
+
*
|
|
173
|
+
* In v4 this works with both provider-created engines (`achievements` prop) and
|
|
174
|
+
* injected engines (`engine` prop).
|
|
175
|
+
*/
|
|
176
|
+
const useAchievementEngine = () => {
|
|
177
|
+
const context = React.useContext(AchievementContext);
|
|
178
|
+
if (!context) {
|
|
179
|
+
throw new Error('useAchievementEngine must be used within an AchievementProvider.\n\n' +
|
|
180
|
+
'Wrap your component tree:\n' +
|
|
181
|
+
'<AchievementProvider achievements={achievements}>\n' +
|
|
182
|
+
' <YourComponent />\n' +
|
|
183
|
+
'</AchievementProvider>');
|
|
184
|
+
}
|
|
185
|
+
return context.engine;
|
|
186
|
+
};
|
|
187
|
+
|
|
22
188
|
/**
|
|
23
189
|
* Built-in theme presets
|
|
24
190
|
*/
|
|
@@ -135,56 +301,6 @@ function getTheme(name) {
|
|
|
135
301
|
return builtInThemes[name];
|
|
136
302
|
}
|
|
137
303
|
|
|
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
304
|
const defaultAchievementIcons = {
|
|
189
305
|
// Essential fallback icons for system use
|
|
190
306
|
default: '⭐', // Fallback when no icon is provided
|
|
@@ -196,184 +312,123 @@ const defaultAchievementIcons = {
|
|
|
196
312
|
star: '⭐',
|
|
197
313
|
};
|
|
198
314
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Built-in notification component
|
|
317
|
+
* Modern, theme-aware achievement notification with smooth animations
|
|
318
|
+
*/
|
|
319
|
+
const BuiltInNotification = ({ achievement, onClose, duration = 5000, position = 'top-center', theme = 'modern', icons = {}, stackIndex = 0, }) => {
|
|
320
|
+
var _a, _b, _c;
|
|
321
|
+
const [isVisible, setIsVisible] = React.useState(false);
|
|
322
|
+
const [isExiting, setIsExiting] = React.useState(false);
|
|
323
|
+
// Merge custom icons with defaults
|
|
324
|
+
const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons);
|
|
325
|
+
// Get theme configuration
|
|
326
|
+
const themeConfig = getTheme(theme) || builtInThemes.modern;
|
|
327
|
+
const { notification: themeStyles } = themeConfig;
|
|
328
|
+
React.useEffect(() => {
|
|
329
|
+
// Slide in animation
|
|
330
|
+
const showTimer = setTimeout(() => setIsVisible(true), 10);
|
|
331
|
+
// Auto-dismiss
|
|
332
|
+
const dismissTimer = setTimeout(() => {
|
|
333
|
+
setIsExiting(true);
|
|
334
|
+
setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
|
|
335
|
+
}, duration);
|
|
336
|
+
return () => {
|
|
337
|
+
clearTimeout(showTimer);
|
|
338
|
+
clearTimeout(dismissTimer);
|
|
339
|
+
};
|
|
340
|
+
}, [duration, onClose]);
|
|
341
|
+
const getPositionStyles = () => {
|
|
342
|
+
const stackedOffset = 20 + stackIndex * 104;
|
|
343
|
+
const base = {
|
|
344
|
+
position: 'fixed',
|
|
345
|
+
zIndex: 9999,
|
|
346
|
+
};
|
|
347
|
+
switch (position) {
|
|
348
|
+
case 'top-center':
|
|
349
|
+
return Object.assign(Object.assign({}, base), { top: stackedOffset, left: '50%', transform: 'translateX(-50%)' });
|
|
350
|
+
case 'top-left':
|
|
351
|
+
return Object.assign(Object.assign({}, base), { top: stackedOffset, left: 20 });
|
|
352
|
+
case 'top-right':
|
|
353
|
+
return Object.assign(Object.assign({}, base), { top: stackedOffset, right: 20 });
|
|
354
|
+
case 'bottom-center':
|
|
355
|
+
return Object.assign(Object.assign({}, base), { bottom: stackedOffset, left: '50%', transform: 'translateX(-50%)' });
|
|
356
|
+
case 'bottom-left':
|
|
357
|
+
return Object.assign(Object.assign({}, base), { bottom: stackedOffset, left: 20 });
|
|
358
|
+
case 'bottom-right':
|
|
359
|
+
return Object.assign(Object.assign({}, base), { bottom: stackedOffset, right: 20 });
|
|
360
|
+
default:
|
|
361
|
+
return Object.assign(Object.assign({}, base), { top: stackedOffset, left: '50%', transform: 'translateX(-50%)' });
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
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')
|
|
365
|
+
? `translateY(${isVisible && !isExiting ? '0' : '-20px'}) ${position.includes('center') ? 'translateX(-50%)' : ''}`
|
|
366
|
+
: `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' });
|
|
367
|
+
const iconStyles = {
|
|
368
|
+
fontSize: '48px',
|
|
369
|
+
lineHeight: 1,
|
|
370
|
+
flexShrink: 0,
|
|
371
|
+
};
|
|
372
|
+
const contentStyles = {
|
|
373
|
+
flex: 1,
|
|
374
|
+
color: themeStyles.textColor,
|
|
375
|
+
minWidth: 0,
|
|
376
|
+
};
|
|
377
|
+
const headerStyles = {
|
|
378
|
+
fontSize: ((_a = themeStyles.fontSize) === null || _a === void 0 ? void 0 : _a.header) || '12px',
|
|
379
|
+
textTransform: 'uppercase',
|
|
380
|
+
letterSpacing: '1px',
|
|
381
|
+
opacity: 0.8,
|
|
382
|
+
marginBottom: '4px',
|
|
383
|
+
color: themeStyles.accentColor,
|
|
384
|
+
fontWeight: 600,
|
|
385
|
+
};
|
|
386
|
+
const titleStyles = {
|
|
387
|
+
fontSize: ((_b = themeStyles.fontSize) === null || _b === void 0 ? void 0 : _b.title) || '18px',
|
|
388
|
+
fontWeight: 'bold',
|
|
389
|
+
marginBottom: '4px',
|
|
390
|
+
overflow: 'hidden',
|
|
391
|
+
textOverflow: 'ellipsis',
|
|
392
|
+
whiteSpace: 'nowrap',
|
|
393
|
+
};
|
|
394
|
+
const descriptionStyles = {
|
|
395
|
+
fontSize: ((_c = themeStyles.fontSize) === null || _c === void 0 ? void 0 : _c.description) || '14px',
|
|
396
|
+
opacity: 0.9,
|
|
397
|
+
overflow: 'hidden',
|
|
398
|
+
textOverflow: 'ellipsis',
|
|
399
|
+
display: '-webkit-box',
|
|
400
|
+
WebkitLineClamp: 2,
|
|
401
|
+
WebkitBoxOrient: 'vertical',
|
|
402
|
+
};
|
|
403
|
+
const closeButtonStyles = {
|
|
404
|
+
background: 'none',
|
|
204
405
|
border: 'none',
|
|
205
|
-
|
|
406
|
+
color: themeStyles.textColor,
|
|
407
|
+
fontSize: '24px',
|
|
206
408
|
cursor: 'pointer',
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
},
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 })));
|
|
409
|
+
opacity: 0.6,
|
|
410
|
+
transition: 'opacity 0.2s',
|
|
411
|
+
padding: '4px',
|
|
412
|
+
flexShrink: 0,
|
|
413
|
+
lineHeight: 1,
|
|
414
|
+
};
|
|
415
|
+
// If achievementIconKey exists but not in mergedIcons, use it directly (might be an emoji)
|
|
416
|
+
// Otherwise, look up in mergedIcons or fall back to default
|
|
417
|
+
const icon = (achievement.achievementIconKey &&
|
|
418
|
+
mergedIcons[achievement.achievementIconKey]) ||
|
|
419
|
+
achievement.achievementIconKey ||
|
|
420
|
+
mergedIcons.default ||
|
|
421
|
+
'⭐';
|
|
422
|
+
return (React.createElement("div", { style: containerStyles, "data-testid": "built-in-notification" },
|
|
423
|
+
React.createElement("div", { style: iconStyles }, icon),
|
|
424
|
+
React.createElement("div", { style: contentStyles },
|
|
425
|
+
React.createElement("div", { style: headerStyles }, "Achievement Unlocked!"),
|
|
426
|
+
React.createElement("div", { style: titleStyles }, achievement.achievementTitle),
|
|
427
|
+
achievement.achievementDescription && (React.createElement("div", { style: descriptionStyles }, achievement.achievementDescription))),
|
|
428
|
+
React.createElement("button", { onClick: () => {
|
|
429
|
+
setIsExiting(true);
|
|
430
|
+
setTimeout(() => onClose === null || onClose === void 0 ? void 0 : onClose(), 300);
|
|
431
|
+
}, style: closeButtonStyles, onMouseEnter: (e) => (e.currentTarget.style.opacity = '1'), onMouseLeave: (e) => (e.currentTarget.style.opacity = '0.6'), "aria-label": "Close notification" }, "\u00D7")));
|
|
377
432
|
};
|
|
378
433
|
|
|
379
434
|
/**
|
|
@@ -416,137 +471,6 @@ function useWindowSize() {
|
|
|
416
471
|
return size;
|
|
417
472
|
}
|
|
418
473
|
|
|
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
474
|
/**
|
|
551
475
|
* Built-in confetti component
|
|
552
476
|
* Lightweight CSS-based confetti animation
|
|
@@ -605,128 +529,565 @@ const BuiltInConfetti = ({ show, duration = 5000, particleCount = 50, colors = [
|
|
|
605
529
|
transform: translateY(0) rotate(0deg);
|
|
606
530
|
opacity: 1;
|
|
607
531
|
}
|
|
608
|
-
100% {
|
|
609
|
-
transform: translateY(${height + 50}px) rotate(720deg);
|
|
610
|
-
opacity: 0;
|
|
532
|
+
100% {
|
|
533
|
+
transform: translateY(${height + 50}px) rotate(720deg);
|
|
534
|
+
opacity: 0;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
`),
|
|
538
|
+
React.createElement("div", { style: containerStyles, "data-testid": "built-in-confetti" }, particles)));
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const NOTIFICATION_DURATION_MS = 5000;
|
|
542
|
+
const AchievementUIContext = React.createContext({
|
|
543
|
+
icons: {},
|
|
544
|
+
ui: {},
|
|
545
|
+
});
|
|
546
|
+
const AchievementEffects = ({ icons, ui }) => {
|
|
547
|
+
const engine = useAchievementEngine();
|
|
548
|
+
const seenAchievementsRef = React.useRef(new Set(engine.getUnlocked()));
|
|
549
|
+
const confettiTimerRef = React.useRef(null);
|
|
550
|
+
const [showConfetti, setShowConfetti] = React.useState(false);
|
|
551
|
+
const [notifications, setNotifications] = React.useState([]);
|
|
552
|
+
React.useEffect(() => {
|
|
553
|
+
const unsubscribeUnlocked = engine.on('achievement:unlocked', (event) => {
|
|
554
|
+
if (seenAchievementsRef.current.has(event.achievementId)) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
seenAchievementsRef.current.add(event.achievementId);
|
|
558
|
+
const unlockedAchievement = {
|
|
559
|
+
achievementId: event.achievementId,
|
|
560
|
+
achievementTitle: event.achievementTitle,
|
|
561
|
+
achievementDescription: event.achievementDescription,
|
|
562
|
+
achievementIconKey: event.achievementIconKey,
|
|
563
|
+
isUnlocked: true,
|
|
564
|
+
};
|
|
565
|
+
if (ui.enableNotifications !== false) {
|
|
566
|
+
setNotifications((currentNotifications) => {
|
|
567
|
+
if (currentNotifications.some((notification) => notification.achievementId === unlockedAchievement.achievementId)) {
|
|
568
|
+
return currentNotifications;
|
|
569
|
+
}
|
|
570
|
+
return [...currentNotifications, unlockedAchievement];
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (ui.enableConfetti !== false) {
|
|
574
|
+
if (confettiTimerRef.current) {
|
|
575
|
+
clearTimeout(confettiTimerRef.current);
|
|
576
|
+
}
|
|
577
|
+
setShowConfetti(true);
|
|
578
|
+
confettiTimerRef.current = setTimeout(() => {
|
|
579
|
+
setShowConfetti(false);
|
|
580
|
+
confettiTimerRef.current = null;
|
|
581
|
+
}, NOTIFICATION_DURATION_MS);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
const unsubscribeStateChanged = engine.on('state:changed', () => {
|
|
585
|
+
const unlocked = new Set(engine.getUnlocked());
|
|
586
|
+
seenAchievementsRef.current.forEach((achievementId) => {
|
|
587
|
+
if (!unlocked.has(achievementId)) {
|
|
588
|
+
seenAchievementsRef.current.delete(achievementId);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
return () => {
|
|
593
|
+
unsubscribeUnlocked();
|
|
594
|
+
unsubscribeStateChanged();
|
|
595
|
+
if (confettiTimerRef.current) {
|
|
596
|
+
clearTimeout(confettiTimerRef.current);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}, [engine, ui.enableConfetti, ui.enableNotifications]);
|
|
600
|
+
const NotificationComponent = ui.NotificationComponent || BuiltInNotification;
|
|
601
|
+
const ConfettiComponentResolved = ui.ConfettiComponent || BuiltInConfetti;
|
|
602
|
+
return (React.createElement(React.Fragment, null,
|
|
603
|
+
ui.enableNotifications !== false &&
|
|
604
|
+
notifications.map((notification, index) => (React.createElement(NotificationComponent, { key: notification.achievementId, achievement: notification, onClose: () => setNotifications((currentNotifications) => currentNotifications.filter((currentNotification) => currentNotification.achievementId !== notification.achievementId)), duration: NOTIFICATION_DURATION_MS, position: ui.notificationPosition || 'top-center', theme: ui.theme || 'modern', icons: icons, stackIndex: index }))),
|
|
605
|
+
ui.enableConfetti !== false && (React.createElement(ConfettiComponentResolved, { show: showConfetti, duration: NOTIFICATION_DURATION_MS }))));
|
|
606
|
+
};
|
|
607
|
+
const AchievementProvider = (_a) => {
|
|
608
|
+
var { children, icons = {}, ui = {}, useBuiltInUI } = _a, providerProps = __rest(_a, ["children", "icons", "ui", "useBuiltInUI"]);
|
|
609
|
+
if (useBuiltInUI !== undefined) {
|
|
610
|
+
warnDeprecation('`useBuiltInUI` is deprecated and is now a no-op because built-in UI is the default. It will be removed in 4.2.');
|
|
611
|
+
}
|
|
612
|
+
const uiContextValue = React.useMemo(() => ({ icons, ui }), [icons, ui]);
|
|
613
|
+
return (React.createElement(AchievementUIContext.Provider, { value: uiContextValue },
|
|
614
|
+
React.createElement(AchievementProvider$1, Object.assign({}, providerProps, { icons: icons }),
|
|
615
|
+
children,
|
|
616
|
+
React.createElement(AchievementEffects, { icons: icons, ui: ui }))));
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const useAchievements = () => {
|
|
620
|
+
const context = React.useContext(AchievementContext);
|
|
621
|
+
if (!context) {
|
|
622
|
+
throw new Error('useAchievements must be used within an AchievementProvider');
|
|
623
|
+
}
|
|
624
|
+
return context;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const useAchievementState = () => {
|
|
628
|
+
const { achievements, getAllAchievements, getState } = useAchievements();
|
|
629
|
+
const allAchievements = getAllAchievements();
|
|
630
|
+
const unlockedIds = achievements.unlocked;
|
|
631
|
+
const unlockedAchievementSet = new Set(unlockedIds);
|
|
632
|
+
const unlockedAchievements = allAchievements.filter((achievement) => unlockedAchievementSet.has(achievement.achievementId));
|
|
633
|
+
return {
|
|
634
|
+
unlockedIds,
|
|
635
|
+
unlockedAchievements,
|
|
636
|
+
allAchievements,
|
|
637
|
+
unlockedCount: unlockedIds.length,
|
|
638
|
+
totalCount: allAchievements.length,
|
|
639
|
+
metrics: getState().metrics,
|
|
640
|
+
};
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const defaultStyles = {
|
|
644
|
+
badgesButton: {
|
|
645
|
+
backgroundColor: '#4CAF50',
|
|
646
|
+
color: 'white',
|
|
647
|
+
padding: '10px 20px',
|
|
648
|
+
border: 'none',
|
|
649
|
+
borderRadius: '20px',
|
|
650
|
+
cursor: 'pointer',
|
|
651
|
+
display: 'flex',
|
|
652
|
+
alignItems: 'center',
|
|
653
|
+
gap: '8px',
|
|
654
|
+
fontSize: '16px',
|
|
655
|
+
boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
|
|
656
|
+
transition: 'transform 0.2s ease-in-out',
|
|
657
|
+
},
|
|
658
|
+
badgesModal: {
|
|
659
|
+
overlay: {
|
|
660
|
+
position: 'fixed',
|
|
661
|
+
top: 0,
|
|
662
|
+
right: 0,
|
|
663
|
+
bottom: 0,
|
|
664
|
+
left: 0,
|
|
665
|
+
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
666
|
+
display: 'flex',
|
|
667
|
+
alignItems: 'center',
|
|
668
|
+
justifyContent: 'center',
|
|
669
|
+
zIndex: 10000,
|
|
670
|
+
},
|
|
671
|
+
content: {
|
|
672
|
+
position: 'relative',
|
|
673
|
+
background: '#fff',
|
|
674
|
+
borderRadius: '8px',
|
|
675
|
+
padding: '20px',
|
|
676
|
+
maxWidth: '500px',
|
|
677
|
+
width: '90%',
|
|
678
|
+
maxHeight: '80vh',
|
|
679
|
+
overflow: 'auto',
|
|
680
|
+
},
|
|
681
|
+
header: {
|
|
682
|
+
display: 'flex',
|
|
683
|
+
justifyContent: 'space-between',
|
|
684
|
+
alignItems: 'center',
|
|
685
|
+
marginBottom: '20px',
|
|
686
|
+
},
|
|
687
|
+
closeButton: {
|
|
688
|
+
background: 'none',
|
|
689
|
+
border: 'none',
|
|
690
|
+
fontSize: '24px',
|
|
691
|
+
cursor: 'pointer',
|
|
692
|
+
padding: '0',
|
|
693
|
+
},
|
|
694
|
+
achievementList: {
|
|
695
|
+
display: 'flex',
|
|
696
|
+
flexDirection: 'column',
|
|
697
|
+
gap: '16px',
|
|
698
|
+
},
|
|
699
|
+
achievementItem: {
|
|
700
|
+
display: 'flex',
|
|
701
|
+
gap: '16px',
|
|
702
|
+
padding: '16px',
|
|
703
|
+
borderRadius: '8px',
|
|
704
|
+
backgroundColor: '#f5f5f5',
|
|
705
|
+
alignItems: 'center',
|
|
706
|
+
},
|
|
707
|
+
achievementTitle: {
|
|
708
|
+
margin: '0',
|
|
709
|
+
fontSize: '18px',
|
|
710
|
+
fontWeight: 'bold',
|
|
711
|
+
},
|
|
712
|
+
achievementDescription: {
|
|
713
|
+
margin: '4px 0 0 0',
|
|
714
|
+
color: '#666',
|
|
715
|
+
},
|
|
716
|
+
achievementIcon: {
|
|
717
|
+
fontSize: '32px',
|
|
718
|
+
display: 'flex',
|
|
719
|
+
alignItems: 'center',
|
|
720
|
+
justifyContent: 'center',
|
|
721
|
+
},
|
|
722
|
+
lockIcon: {
|
|
723
|
+
fontSize: '24px',
|
|
724
|
+
position: 'absolute',
|
|
725
|
+
top: '50%',
|
|
726
|
+
right: '16px',
|
|
727
|
+
transform: 'translateY(-50%)',
|
|
728
|
+
},
|
|
729
|
+
lockedAchievementItem: {
|
|
730
|
+
display: 'flex',
|
|
731
|
+
gap: '16px',
|
|
732
|
+
padding: '16px',
|
|
733
|
+
borderRadius: '8px',
|
|
734
|
+
backgroundColor: '#e0e0e0',
|
|
735
|
+
alignItems: 'center',
|
|
736
|
+
opacity: 0.5,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
levelProgress: {
|
|
740
|
+
container: {
|
|
741
|
+
display: 'flex',
|
|
742
|
+
flexDirection: 'column',
|
|
743
|
+
gap: '8px',
|
|
744
|
+
padding: '12px 16px',
|
|
745
|
+
},
|
|
746
|
+
header: {
|
|
747
|
+
display: 'flex',
|
|
748
|
+
alignItems: 'center',
|
|
749
|
+
justifyContent: 'space-between',
|
|
750
|
+
gap: '12px',
|
|
751
|
+
fontWeight: 600,
|
|
752
|
+
},
|
|
753
|
+
levelLabel: {
|
|
754
|
+
fontSize: '14px',
|
|
755
|
+
},
|
|
756
|
+
valueText: {
|
|
757
|
+
fontSize: '12px',
|
|
758
|
+
opacity: 0.8,
|
|
759
|
+
},
|
|
760
|
+
progressTrack: {
|
|
761
|
+
position: 'relative',
|
|
762
|
+
height: '10px',
|
|
763
|
+
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
|
764
|
+
borderRadius: '999px',
|
|
765
|
+
overflow: 'hidden',
|
|
766
|
+
},
|
|
767
|
+
progressBar: {
|
|
768
|
+
height: '100%',
|
|
769
|
+
borderRadius: '999px',
|
|
770
|
+
transition: 'width 0.2s ease-in-out',
|
|
771
|
+
},
|
|
772
|
+
progressText: {
|
|
773
|
+
fontSize: '12px',
|
|
774
|
+
opacity: 0.8,
|
|
775
|
+
textAlign: 'right',
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const resolveIcon = (achievement, icons) => {
|
|
781
|
+
return ((achievement.achievementIconKey && icons[achievement.achievementIconKey]) ||
|
|
782
|
+
achievement.achievementIconKey ||
|
|
783
|
+
icons.default ||
|
|
784
|
+
'⭐');
|
|
785
|
+
};
|
|
786
|
+
const AchievementsList = ({ achievements, showLocked = true, showUnlockConditions = false, icons = {}, styles = {}, emptyState, className, renderAchievement, }) => {
|
|
787
|
+
const context = React.useContext(AchievementContext);
|
|
788
|
+
const uiContext = React.useContext(AchievementUIContext);
|
|
789
|
+
const mergedIcons = Object.assign(Object.assign(Object.assign(Object.assign({}, defaultAchievementIcons), context === null || context === void 0 ? void 0 : context.icons), uiContext.icons), icons);
|
|
790
|
+
const sourceAchievements = achievements || (context === null || context === void 0 ? void 0 : context.getAllAchievements());
|
|
791
|
+
if (!sourceAchievements) {
|
|
792
|
+
throw new Error('AchievementsList requires either an achievements prop or an AchievementProvider parent.');
|
|
793
|
+
}
|
|
794
|
+
const achievementsToDisplay = showLocked
|
|
795
|
+
? sourceAchievements
|
|
796
|
+
: sourceAchievements.filter((achievement) => achievement.isUnlocked);
|
|
797
|
+
if (achievementsToDisplay.length === 0) {
|
|
798
|
+
return (React.createElement("p", { style: { textAlign: 'center', color: '#666' } }, emptyState || 'No achievements configured.'));
|
|
799
|
+
}
|
|
800
|
+
return (React.createElement("div", { className: className, style: Object.assign(Object.assign({}, defaultStyles.badgesModal.achievementList), styles === null || styles === void 0 ? void 0 : styles.achievementList), "data-testid": "achievements-list" }, achievementsToDisplay.map((achievement, index) => {
|
|
801
|
+
const isLocked = !achievement.isUnlocked;
|
|
802
|
+
const icon = resolveIcon(achievement, mergedIcons);
|
|
803
|
+
if (renderAchievement) {
|
|
804
|
+
return (React.createElement(React.Fragment, { key: achievement.achievementId }, renderAchievement({ achievement, isLocked, icon, index })));
|
|
805
|
+
}
|
|
806
|
+
return (React.createElement("div", { key: achievement.achievementId, style: Object.assign(Object.assign(Object.assign({}, (isLocked
|
|
807
|
+
? 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' }), "data-testid": "achievement-list-item", "data-unlocked": achievement.isUnlocked ? 'true' : 'false' },
|
|
808
|
+
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 }) }, icon),
|
|
809
|
+
React.createElement("div", { style: { flex: 1 } },
|
|
810
|
+
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),
|
|
811
|
+
achievement.achievementDescription && (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' }) },
|
|
812
|
+
achievement.achievementDescription,
|
|
813
|
+
showUnlockConditions && isLocked && (React.createElement("span", { style: {
|
|
814
|
+
display: 'block',
|
|
815
|
+
fontSize: '12px',
|
|
816
|
+
marginTop: '4px',
|
|
817
|
+
fontStyle: 'italic',
|
|
818
|
+
color: '#888',
|
|
819
|
+
} },
|
|
820
|
+
"\uD83D\uDD13 ",
|
|
821
|
+
achievement.achievementDescription))))),
|
|
822
|
+
isLocked && (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.lockIcon), styles === null || styles === void 0 ? void 0 : styles.lockIcon) }, "\uD83D\uDD12"))));
|
|
823
|
+
})));
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const AchievementsModal = ({ isOpen, onClose, achievements, title = '🏆 Achievements', styles = {}, icons = {}, showLocked = true, showUnlockConditions = false, emptyState, renderAchievement, theme, }) => {
|
|
827
|
+
const context = React.useContext(AchievementContext);
|
|
828
|
+
const uiContext = React.useContext(AchievementUIContext);
|
|
829
|
+
React.useEffect(() => {
|
|
830
|
+
if (!isOpen || typeof document === 'undefined') {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const previousOverflow = document.body.style.overflow;
|
|
834
|
+
document.body.style.overflow = 'hidden';
|
|
835
|
+
const handleKeyDown = (event) => {
|
|
836
|
+
if (event.key === 'Escape') {
|
|
837
|
+
onClose();
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
841
|
+
return () => {
|
|
842
|
+
document.body.style.overflow = previousOverflow;
|
|
843
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
844
|
+
};
|
|
845
|
+
}, [isOpen, onClose]);
|
|
846
|
+
if (!isOpen)
|
|
847
|
+
return null;
|
|
848
|
+
const CustomModal = uiContext.ui.ModalComponent;
|
|
849
|
+
const sourceAchievements = achievements || (context === null || context === void 0 ? void 0 : context.getAllAchievements());
|
|
850
|
+
const modalAchievements = showLocked
|
|
851
|
+
? sourceAchievements
|
|
852
|
+
: sourceAchievements === null || sourceAchievements === void 0 ? void 0 : sourceAchievements.filter((achievement) => achievement.isUnlocked);
|
|
853
|
+
const resolvedTheme = theme || uiContext.ui.theme || 'modern';
|
|
854
|
+
const mergedIcons = Object.assign(Object.assign(Object.assign(Object.assign({}, defaultAchievementIcons), context === null || context === void 0 ? void 0 : context.icons), uiContext.icons), icons);
|
|
855
|
+
if (CustomModal) {
|
|
856
|
+
if (!modalAchievements) {
|
|
857
|
+
throw new Error('AchievementsModal requires either an achievements prop or an AchievementProvider parent.');
|
|
858
|
+
}
|
|
859
|
+
return (React.createElement(CustomModal, { isOpen: isOpen, onClose: onClose, achievements: modalAchievements, icons: mergedIcons, theme: resolvedTheme }));
|
|
860
|
+
}
|
|
861
|
+
return (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.overlay), styles === null || styles === void 0 ? void 0 : styles.overlay), role: "presentation", onClick: onClose, "data-testid": "achievements-modal-overlay" },
|
|
862
|
+
React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.content), styles === null || styles === void 0 ? void 0 : styles.content), role: "dialog", "aria-modal": "true", "aria-labelledby": "achievements-modal-title", onClick: (event) => event.stopPropagation(), "data-testid": "achievements-modal" },
|
|
863
|
+
React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.badgesModal.header), styles === null || styles === void 0 ? void 0 : styles.header) },
|
|
864
|
+
React.createElement("h2", { id: "achievements-modal-title", style: { margin: 0 } }, title),
|
|
865
|
+
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")),
|
|
866
|
+
React.createElement(AchievementsList, { achievements: modalAchievements, showLocked: showLocked, showUnlockConditions: showUnlockConditions, icons: icons, styles: styles, emptyState: emptyState, renderAchievement: renderAchievement }))));
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const getPositionStyles$1 = (position) => {
|
|
870
|
+
const base = {
|
|
871
|
+
position: 'fixed',
|
|
872
|
+
margin: '20px',
|
|
873
|
+
zIndex: 1000,
|
|
874
|
+
};
|
|
875
|
+
switch (position) {
|
|
876
|
+
case 'top-left':
|
|
877
|
+
return Object.assign(Object.assign({}, base), { top: 0, left: 0 });
|
|
878
|
+
case 'top-right':
|
|
879
|
+
return Object.assign(Object.assign({}, base), { top: 0, right: 0 });
|
|
880
|
+
case 'bottom-left':
|
|
881
|
+
return Object.assign(Object.assign({}, base), { bottom: 0, left: 0 });
|
|
882
|
+
case 'bottom-right':
|
|
883
|
+
return Object.assign(Object.assign({}, base), { bottom: 0, right: 0 });
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
const AchievementsWidget = ({ position = 'bottom-right', placement = 'fixed', showAllAchievements = true, showUnlockConditions = false, showCount = true, icons, theme, label = 'Achievements', icon = '🏆', triggerClassName, renderTrigger, buttonStyles, modalStyles, modalTitle, emptyState, renderAchievement, }) => {
|
|
887
|
+
const uiContext = React.useContext(AchievementUIContext);
|
|
888
|
+
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
|
889
|
+
const { unlockedAchievements, allAchievements, unlockedCount, totalCount } = useAchievementState();
|
|
890
|
+
const resolvedTheme = theme || uiContext.ui.theme || 'modern';
|
|
891
|
+
const themeConfig = getTheme(resolvedTheme) || builtInThemes.modern;
|
|
892
|
+
const modalAchievements = showAllAchievements ? allAchievements : unlockedAchievements;
|
|
893
|
+
const openModal = () => setIsModalOpen(true);
|
|
894
|
+
const buttonBaseStyles = placement === 'inline'
|
|
895
|
+
? Object.assign({ backgroundColor: 'transparent', color: 'inherit', padding: '10px 12px', border: 'none', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', font: 'inherit', width: '100%', textAlign: 'left' }, buttonStyles) : Object.assign(Object.assign({ backgroundColor: themeConfig.notification.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$1(position)), buttonStyles);
|
|
896
|
+
const buttonProps = {
|
|
897
|
+
type: 'button',
|
|
898
|
+
onClick: openModal,
|
|
899
|
+
style: buttonBaseStyles,
|
|
900
|
+
className: triggerClassName,
|
|
901
|
+
'data-placement': placement,
|
|
902
|
+
'data-testid': 'achievements-widget-button',
|
|
903
|
+
'aria-label': `${label}: ${unlockedCount} of ${totalCount} achievements unlocked`,
|
|
904
|
+
};
|
|
905
|
+
return (React.createElement(React.Fragment, null,
|
|
906
|
+
renderTrigger ? (renderTrigger({
|
|
907
|
+
open: openModal,
|
|
908
|
+
label,
|
|
909
|
+
unlockedCount,
|
|
910
|
+
totalCount,
|
|
911
|
+
unlockedAchievements,
|
|
912
|
+
allAchievements,
|
|
913
|
+
buttonProps,
|
|
914
|
+
})) : (React.createElement("button", Object.assign({}, buttonProps, { onMouseEnter: (event) => {
|
|
915
|
+
if (placement !== 'inline') {
|
|
916
|
+
event.currentTarget.style.transform = 'scale(1.05)';
|
|
917
|
+
}
|
|
918
|
+
}, onMouseLeave: (event) => {
|
|
919
|
+
if (placement !== 'inline') {
|
|
920
|
+
event.currentTarget.style.transform = 'scale(1)';
|
|
921
|
+
}
|
|
922
|
+
} }),
|
|
923
|
+
React.createElement("span", null, icon),
|
|
924
|
+
React.createElement("span", { style: { flex: 1 } }, label),
|
|
925
|
+
showCount && React.createElement("span", null, unlockedCount))),
|
|
926
|
+
React.createElement(AchievementsModal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), achievements: modalAchievements, showUnlockConditions: showUnlockConditions, icons: icons, styles: modalStyles, title: modalTitle, emptyState: emptyState, renderAchievement: renderAchievement, theme: resolvedTheme })));
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const getPositionStyles = (position) => {
|
|
930
|
+
const base = {
|
|
931
|
+
position: 'fixed',
|
|
932
|
+
margin: '20px',
|
|
933
|
+
zIndex: 1000,
|
|
934
|
+
};
|
|
935
|
+
switch (position) {
|
|
936
|
+
case 'top-left':
|
|
937
|
+
return Object.assign(Object.assign({}, base), { top: 0, left: 0 });
|
|
938
|
+
case 'top-right':
|
|
939
|
+
return Object.assign(Object.assign({}, base), { top: 0, right: 0 });
|
|
940
|
+
case 'bottom-left':
|
|
941
|
+
return Object.assign(Object.assign({}, base), { bottom: 0, left: 0 });
|
|
942
|
+
case 'bottom-right':
|
|
943
|
+
return Object.assign(Object.assign({}, base), { bottom: 0, right: 0 });
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
/**
|
|
947
|
+
* @deprecated Use `AchievementsWidget` for new integrations. This v3
|
|
948
|
+
* compatibility wrapper will be removed in 4.2.
|
|
949
|
+
*/
|
|
950
|
+
const BadgesButton = ({ onClick, position = 'bottom-right', placement = 'fixed', styles = {}, unlockedAchievements, theme = 'modern', }) => {
|
|
951
|
+
React.useEffect(() => {
|
|
952
|
+
warnDeprecation('`BadgesButton` is deprecated. Use `AchievementsWidget` instead. `BadgesButton` will be removed in 4.2.');
|
|
953
|
+
}, []);
|
|
954
|
+
// Get theme configuration for consistent styling
|
|
955
|
+
const themeConfig = getTheme(theme) || builtInThemes.modern;
|
|
956
|
+
const accentColor = themeConfig.notification.accentColor;
|
|
957
|
+
// Different styling for fixed vs inline placement
|
|
958
|
+
const baseStyles = placement === 'inline'
|
|
959
|
+
? Object.assign({
|
|
960
|
+
// Inline mode: looks like a navigation item
|
|
961
|
+
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({
|
|
962
|
+
// Fixed mode: floating button
|
|
963
|
+
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);
|
|
964
|
+
return (React.createElement("button", { onClick: onClick, style: baseStyles, onMouseEnter: (e) => {
|
|
965
|
+
if (placement === 'inline') {
|
|
966
|
+
// Inline mode: subtle background color change
|
|
967
|
+
e.target.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
// Fixed mode: scale transformation
|
|
971
|
+
e.target.style.transform = 'scale(1.05)';
|
|
972
|
+
}
|
|
973
|
+
}, onMouseLeave: (e) => {
|
|
974
|
+
if (placement === 'inline') {
|
|
975
|
+
e.target.style.backgroundColor = 'transparent';
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
e.target.style.transform = 'scale(1)';
|
|
611
979
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
980
|
+
}, "data-placement": placement, "data-testid": "badges-button" },
|
|
981
|
+
"\uD83C\uDFC6 Achievements (",
|
|
982
|
+
unlockedAchievements.length,
|
|
983
|
+
")"));
|
|
615
984
|
};
|
|
616
985
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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;
|
|
986
|
+
/**
|
|
987
|
+
* @deprecated Use `AchievementsModal`, `AchievementsWidget`, or
|
|
988
|
+
* `AchievementsList` for new integrations. This v3 compatibility wrapper will
|
|
989
|
+
* be removed in 4.2.
|
|
990
|
+
*/
|
|
991
|
+
const BadgesModal = ({ isOpen, onClose, achievements, styles = {}, icons = {}, showAllAchievements = false, showUnlockConditions = false, allAchievements, }) => {
|
|
992
|
+
React.useEffect(() => {
|
|
993
|
+
warnDeprecation('`BadgesModal` is deprecated. Use `AchievementsWidget` or `AchievementsList` instead. `BadgesModal` will be removed in 4.2.');
|
|
994
|
+
}, []);
|
|
995
|
+
const achievementsToDisplay = showAllAchievements && allAchievements
|
|
996
|
+
? allAchievements
|
|
997
|
+
: achievements.map((achievement) => (Object.assign(Object.assign({}, achievement), { isUnlocked: true })));
|
|
998
|
+
return (React.createElement(AchievementsModal, { isOpen: isOpen, onClose: onClose, achievements: achievementsToDisplay, styles: styles, icons: icons, showUnlockConditions: showUnlockConditions }));
|
|
647
999
|
};
|
|
648
1000
|
|
|
649
1001
|
/**
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
* Shows deprecation warnings when detected
|
|
1002
|
+
* @deprecated Use `AchievementsWidget` for new integrations. This v3
|
|
1003
|
+
* compatibility wrapper will be removed in 4.2.
|
|
653
1004
|
*/
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1005
|
+
const BadgesButtonWithModal = ({ unlockedAchievements, position = 'bottom-right', placement = 'fixed', showAllAchievements = false, allAchievements, showUnlockConditions = false, icons, theme = 'modern', buttonStyles, modalStyles, }) => {
|
|
1006
|
+
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
|
1007
|
+
return (React.createElement(React.Fragment, null,
|
|
1008
|
+
React.createElement(BadgesButton, { onClick: () => setIsModalOpen(true), unlockedAchievements: unlockedAchievements, position: position, placement: placement, theme: theme, styles: buttonStyles }),
|
|
1009
|
+
React.createElement(BadgesModal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), achievements: unlockedAchievements, showAllAchievements: showAllAchievements, allAchievements: allAchievements, showUnlockConditions: showUnlockConditions, icons: icons, styles: modalStyles })));
|
|
1010
|
+
};
|
|
1011
|
+
|
|
657
1012
|
/**
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
* Caches result to avoid multiple import attempts
|
|
661
|
-
*
|
|
662
|
-
* @returns Promise resolving to LegacyLibraries object
|
|
1013
|
+
* @deprecated Use the provider `ui.ConfettiComponent` option or the built-in
|
|
1014
|
+
* confetti default. This v3 compatibility wrapper will be removed in 4.2.
|
|
663
1015
|
*/
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1016
|
+
const ConfettiWrapper = ({ show }) => {
|
|
1017
|
+
React.useEffect(() => {
|
|
1018
|
+
warnDeprecation('`ConfettiWrapper` is deprecated. Use the provider `ui.ConfettiComponent` option or built-in confetti defaults instead. `ConfettiWrapper` will be removed in 4.2.');
|
|
1019
|
+
}, []);
|
|
1020
|
+
return React.createElement(BuiltInConfetti, { show: show, particleCount: 200 });
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const clamp = (value, min, max) => {
|
|
1024
|
+
return Math.min(Math.max(value, min), max);
|
|
1025
|
+
};
|
|
1026
|
+
const LevelProgress = ({ level, currentXP, nextLevelXP, label = 'Level', valueLabel, showValues = true, showPercent = true, theme = 'modern', styles = {}, className, }) => {
|
|
1027
|
+
const themeConfig = getTheme(theme) || builtInThemes.modern;
|
|
1028
|
+
const safeMax = nextLevelXP > 0 ? nextLevelXP : 1;
|
|
1029
|
+
const clampedCurrent = clamp(currentXP, 0, safeMax);
|
|
1030
|
+
const progress = Math.round((clampedCurrent / safeMax) * 100);
|
|
1031
|
+
const computedValueLabel = valueLabel !== null && valueLabel !== void 0 ? valueLabel : `${clampedCurrent} / ${safeMax} XP`;
|
|
1032
|
+
const containerStyles = Object.assign(Object.assign(Object.assign({}, defaultStyles.levelProgress.container), { background: themeConfig.notification.background, color: themeConfig.notification.textColor, borderRadius: themeConfig.notification.borderRadius, boxShadow: themeConfig.notification.boxShadow }), styles === null || styles === void 0 ? void 0 : styles.container);
|
|
1033
|
+
const progressBarStyles = Object.assign(Object.assign(Object.assign({}, defaultStyles.levelProgress.progressBar), { backgroundColor: themeConfig.notification.accentColor, width: `${progress}%` }), styles === null || styles === void 0 ? void 0 : styles.progressBar);
|
|
1034
|
+
return (React.createElement("div", { className: className, style: containerStyles, "data-testid": "level-progress" },
|
|
1035
|
+
React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.levelProgress.header), styles === null || styles === void 0 ? void 0 : styles.header) },
|
|
1036
|
+
React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.levelProgress.levelLabel), styles === null || styles === void 0 ? void 0 : styles.levelLabel) },
|
|
1037
|
+
label,
|
|
1038
|
+
" ",
|
|
1039
|
+
level),
|
|
1040
|
+
showValues && (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.levelProgress.valueText), styles === null || styles === void 0 ? void 0 : styles.valueText) }, computedValueLabel))),
|
|
1041
|
+
React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.levelProgress.progressTrack), styles === null || styles === void 0 ? void 0 : styles.progressTrack), role: "progressbar", "aria-valuemin": 0, "aria-valuemax": safeMax, "aria-valuenow": clampedCurrent, "aria-label": "Level progress" },
|
|
1042
|
+
React.createElement("div", { style: progressBarStyles })),
|
|
1043
|
+
showPercent && (React.createElement("div", { style: Object.assign(Object.assign({}, defaultStyles.levelProgress.progressText), styles === null || styles === void 0 ? void 0 : styles.progressText) },
|
|
1044
|
+
progress,
|
|
1045
|
+
"%"))));
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* A simplified hook for achievement tracking.
|
|
1050
|
+
* Provides the v4 happy path for direct metric updates plus explicit state names.
|
|
1051
|
+
*/
|
|
1052
|
+
const useSimpleAchievements = () => {
|
|
1053
|
+
const { update, reset, getState, exportData, importData } = useAchievements();
|
|
1054
|
+
const achievementState = useAchievementState();
|
|
1055
|
+
const track = (metric, value) => update({ [metric]: value });
|
|
1056
|
+
const increment = (metric, amount = 1) => {
|
|
1057
|
+
const currentState = getState();
|
|
1058
|
+
const currentMetricArray = currentState.metrics[metric] || [0];
|
|
1059
|
+
const currentValue = Array.isArray(currentMetricArray)
|
|
1060
|
+
? currentMetricArray[0]
|
|
1061
|
+
: currentMetricArray;
|
|
1062
|
+
const newValue = (typeof currentValue === 'number' ? currentValue : 0) + amount;
|
|
1063
|
+
update({ [metric]: newValue });
|
|
1064
|
+
};
|
|
1065
|
+
const trackMultiple = (metrics) => update(metrics);
|
|
1066
|
+
return {
|
|
1067
|
+
track,
|
|
1068
|
+
increment,
|
|
1069
|
+
trackMultiple,
|
|
1070
|
+
unlockedIds: achievementState.unlockedIds,
|
|
1071
|
+
unlockedAchievements: achievementState.unlockedAchievements,
|
|
1072
|
+
allAchievements: achievementState.allAchievements,
|
|
1073
|
+
unlockedCount: achievementState.unlockedCount,
|
|
1074
|
+
totalCount: achievementState.totalCount,
|
|
1075
|
+
metrics: achievementState.metrics,
|
|
1076
|
+
reset,
|
|
1077
|
+
getState,
|
|
1078
|
+
exportData,
|
|
1079
|
+
importData,
|
|
1080
|
+
getAllAchievements: () => achievementState.allAchievements,
|
|
1081
|
+
/**
|
|
1082
|
+
* @deprecated Use `unlockedIds` instead. This alias will be removed in 4.2.
|
|
1083
|
+
*/
|
|
1084
|
+
unlocked: achievementState.unlockedIds,
|
|
1085
|
+
/**
|
|
1086
|
+
* @deprecated Use `allAchievements` instead. This alias will be removed in 4.2.
|
|
1087
|
+
*/
|
|
1088
|
+
all: achievementState.allAchievements,
|
|
1089
|
+
};
|
|
1090
|
+
};
|
|
730
1091
|
|
|
731
1092
|
/**
|
|
732
1093
|
* Built-in modal component
|
|
@@ -942,415 +1303,6 @@ const BuiltInModal = ({ isOpen, onClose, achievements, icons = {}, theme = 'mode
|
|
|
942
1303
|
})))))));
|
|
943
1304
|
};
|
|
944
1305
|
|
|
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
1306
|
Object.defineProperty(exports, 'AchievementBuilder', {
|
|
1355
1307
|
enumerable: true,
|
|
1356
1308
|
get: function () { return achievementsEngine.AchievementBuilder; }
|
|
@@ -1441,6 +1393,9 @@ Object.defineProperty(exports, 'normalizeAchievements', {
|
|
|
1441
1393
|
});
|
|
1442
1394
|
exports.AchievementContext = AchievementContext;
|
|
1443
1395
|
exports.AchievementProvider = AchievementProvider;
|
|
1396
|
+
exports.AchievementsList = AchievementsList;
|
|
1397
|
+
exports.AchievementsModal = AchievementsModal;
|
|
1398
|
+
exports.AchievementsWidget = AchievementsWidget;
|
|
1444
1399
|
exports.BadgesButton = BadgesButton;
|
|
1445
1400
|
exports.BadgesButtonWithModal = BadgesButtonWithModal;
|
|
1446
1401
|
exports.BadgesModal = BadgesModal;
|
|
@@ -1448,10 +1403,13 @@ exports.BuiltInConfetti = BuiltInConfetti;
|
|
|
1448
1403
|
exports.BuiltInModal = BuiltInModal;
|
|
1449
1404
|
exports.BuiltInNotification = BuiltInNotification;
|
|
1450
1405
|
exports.ConfettiWrapper = ConfettiWrapper;
|
|
1406
|
+
exports.HeadlessAchievementProvider = AchievementProvider$1;
|
|
1407
|
+
exports.LevelProgress = LevelProgress;
|
|
1451
1408
|
exports.defaultAchievementIcons = defaultAchievementIcons;
|
|
1452
1409
|
exports.defaultStyles = defaultStyles;
|
|
1453
1410
|
exports.isAsyncStorage = isAsyncStorage;
|
|
1454
1411
|
exports.useAchievementEngine = useAchievementEngine;
|
|
1412
|
+
exports.useAchievementState = useAchievementState;
|
|
1455
1413
|
exports.useAchievements = useAchievements;
|
|
1456
1414
|
exports.useSimpleAchievements = useSimpleAchievements;
|
|
1457
1415
|
exports.useWindowSize = useWindowSize;
|