react-achievements 4.4.0 → 5.0.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 +53 -45
- package/dist/headless.cjs +300 -12
- package/dist/headless.cjs.map +1 -1
- package/dist/headless.d.ts +109 -17
- package/dist/headless.esm.js +299 -13
- package/dist/headless.esm.js.map +1 -1
- package/dist/index.cjs +348 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +109 -17
- package/dist/index.esm.js +347 -66
- package/dist/index.esm.js.map +1 -1
- package/dist/web.cjs +348 -65
- package/dist/web.cjs.map +1 -1
- package/dist/web.d.ts +109 -17
- package/dist/web.esm.js +347 -66
- package/dist/web.esm.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
# React Achievements
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Display server-backed achievements in React** - fetch achievement state from your app server, track progress through REST calls, show badges, and celebrate unlocks.
|
|
4
4
|
|
|
5
5
|
[📚 Documentation](https://dave-b-b.github.io/react-achievements/) | [📦 npm Package](https://www.npmjs.com/package/react-achievements)
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/react-achievements) [-blue.svg)](./LICENSE) [](https://www.typescriptlang.org/)
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/>
|
|
16
|
-
</a>
|
|
10
|
+
<img
|
|
11
|
+
src="https://raw.githubusercontent.com/dave-b-b/react-achievements/main/assets/demo-v4-4.gif"
|
|
12
|
+
alt="React Achievements 4.4 demo showing achievement progress, unlock notifications, configurable confetti, and a compact LearnQuest achievements modal"
|
|
13
|
+
width="900"
|
|
14
|
+
/>
|
|
17
15
|
</p>
|
|
18
16
|
|
|
19
17
|
## Installation
|
|
@@ -24,34 +22,38 @@ npm install react-achievements
|
|
|
24
22
|
|
|
25
23
|
**Requirements:** React 16.8+, Node.js 16+
|
|
26
24
|
|
|
25
|
+
If you want the official JavaScript backend engine, install `achievements-engine` in your server package. React Achievements can also display achievement data from Rails Merit or any backend that implements the same REST contract.
|
|
26
|
+
|
|
27
27
|
## Start Here
|
|
28
28
|
|
|
29
29
|
```tsx
|
|
30
30
|
import {
|
|
31
31
|
AchievementProvider,
|
|
32
32
|
AchievementsWidget,
|
|
33
|
+
createRestAchievementClient,
|
|
33
34
|
useSimpleAchievements,
|
|
34
35
|
} from 'react-achievements';
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
};
|
|
37
|
+
const achievementsClient = createRestAchievementClient({
|
|
38
|
+
baseUrl: '/api/achievements',
|
|
39
|
+
});
|
|
41
40
|
|
|
42
41
|
function Game() {
|
|
43
|
-
const { track } = useSimpleAchievements();
|
|
42
|
+
const { track, event } = useSimpleAchievements();
|
|
44
43
|
|
|
45
44
|
return (
|
|
46
|
-
|
|
47
|
-
Score 100
|
|
48
|
-
|
|
45
|
+
<>
|
|
46
|
+
<button onClick={() => track('score', 100)}>Score 100</button>
|
|
47
|
+
<button onClick={() => event('lesson.completed', { lessonId: 'intro' })}>
|
|
48
|
+
Complete lesson
|
|
49
|
+
</button>
|
|
50
|
+
</>
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export default function App() {
|
|
53
55
|
return (
|
|
54
|
-
<AchievementProvider
|
|
56
|
+
<AchievementProvider client={achievementsClient}>
|
|
55
57
|
<Game />
|
|
56
58
|
<AchievementsWidget />
|
|
57
59
|
</AchievementProvider>
|
|
@@ -61,6 +63,18 @@ export default function App() {
|
|
|
61
63
|
|
|
62
64
|
`AchievementsWidget` reads from context, shows the unlocked count, and opens a modal with locked and unlocked achievements. Use `placement="inline"` to put it in a drawer, sidebar, or navigation area. For an exact visual match, pass `renderTrigger` and use your app's own drawer row, nav item, or profile menu button while the widget still controls the modal.
|
|
63
65
|
|
|
66
|
+
Your server should expose this contract:
|
|
67
|
+
|
|
68
|
+
```http
|
|
69
|
+
GET /api/achievements
|
|
70
|
+
POST /api/achievements/track
|
|
71
|
+
POST /api/achievements/increment
|
|
72
|
+
POST /api/achievements/event
|
|
73
|
+
POST /api/achievements/reset
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The official `achievements-engine` package can run those routes on your server, but any backend can return the same snapshot shape.
|
|
77
|
+
|
|
64
78
|
```tsx
|
|
65
79
|
<AchievementsWidget
|
|
66
80
|
placement="inline"
|
|
@@ -122,7 +136,7 @@ Provider-level icons and UI options are shared across notifications, widgets, mo
|
|
|
122
136
|
|
|
123
137
|
```tsx
|
|
124
138
|
<AchievementProvider
|
|
125
|
-
|
|
139
|
+
client={achievementsClient}
|
|
126
140
|
icons={{ login: '🔑', streak: '🔥' }}
|
|
127
141
|
ui={{
|
|
128
142
|
theme: 'minimal',
|
|
@@ -144,7 +158,7 @@ Provider-level icons and UI options are shared across notifications, widgets, mo
|
|
|
144
158
|
|
|
145
159
|
Set `ui.enableConfetti` to `false` to disable the built-in celebration. Omit `ConfettiComponent` when you only want to tune the default `canvas-confetti` animation through `ui.confetti`.
|
|
146
160
|
|
|
147
|
-
Rewards can optionally override the global confetti settings. Omit `confetti` to use the provider defaults, or set `confetti: false` for quieter rewards:
|
|
161
|
+
Rewards returned by your server can optionally include `confetti` to override the global confetti settings. Omit `confetti` to use the provider defaults, or set `confetti: false` for quieter rewards:
|
|
148
162
|
|
|
149
163
|
```tsx
|
|
150
164
|
const achievements = {
|
|
@@ -175,6 +189,7 @@ const achievements = {
|
|
|
175
189
|
const {
|
|
176
190
|
track,
|
|
177
191
|
increment,
|
|
192
|
+
event,
|
|
178
193
|
trackMultiple,
|
|
179
194
|
unlockedIds,
|
|
180
195
|
unlockedAchievements,
|
|
@@ -186,41 +201,34 @@ const {
|
|
|
186
201
|
|
|
187
202
|
Deprecated aliases from v3, including `unlocked` and `all`, remain available until 5.0.
|
|
188
203
|
|
|
189
|
-
##
|
|
204
|
+
## Server Engine Example
|
|
190
205
|
|
|
191
|
-
|
|
206
|
+
Use `achievements-engine` on your server when you want JavaScript rule evaluation and persistence adapters:
|
|
192
207
|
|
|
193
|
-
```
|
|
208
|
+
```ts
|
|
194
209
|
import {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
} from 'react-achievements';
|
|
210
|
+
AchievementService,
|
|
211
|
+
MemoryAchievementRepository,
|
|
212
|
+
createAchievementFetchHandler,
|
|
213
|
+
} from 'achievements-engine/server';
|
|
200
214
|
|
|
201
|
-
const
|
|
215
|
+
const service = new AchievementService({
|
|
202
216
|
achievements,
|
|
217
|
+
repository: new MemoryAchievementRepository(),
|
|
203
218
|
eventMapping: {
|
|
204
|
-
|
|
219
|
+
'lesson.completed': () => ({ completedLesson: true }),
|
|
205
220
|
},
|
|
206
|
-
storage: 'local',
|
|
207
221
|
});
|
|
208
222
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
export default function App() {
|
|
215
|
-
return (
|
|
216
|
-
<AchievementProvider engine={engine}>
|
|
217
|
-
<Game />
|
|
218
|
-
<AchievementsWidget />
|
|
219
|
-
</AchievementProvider>
|
|
220
|
-
);
|
|
221
|
-
}
|
|
223
|
+
export const handleAchievementsRequest = createAchievementFetchHandler({
|
|
224
|
+
service,
|
|
225
|
+
basePath: '/api/achievements',
|
|
226
|
+
getSubjectId: (request) => getUserIdFromSession(request),
|
|
227
|
+
});
|
|
222
228
|
```
|
|
223
229
|
|
|
230
|
+
For Rails Merit or a custom backend, expose the same REST endpoints and return the same achievement snapshot fields.
|
|
231
|
+
|
|
224
232
|
## Headless Usage
|
|
225
233
|
|
|
226
234
|
Use the DOM-free entry point when building custom UI or preparing a React Native integration:
|
|
@@ -252,7 +260,7 @@ function CustomAchievementsPanel() {
|
|
|
252
260
|
|
|
253
261
|
export function App() {
|
|
254
262
|
return (
|
|
255
|
-
<AchievementProvider
|
|
263
|
+
<AchievementProvider client={achievementsClient}>
|
|
256
264
|
<CustomAchievementsPanel />
|
|
257
265
|
</AchievementProvider>
|
|
258
266
|
);
|
package/dist/headless.cjs
CHANGED
|
@@ -17,6 +17,38 @@ var StorageType;
|
|
|
17
17
|
StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
|
|
18
18
|
})(StorageType || (StorageType = {}));
|
|
19
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 __awaiter(thisArg, _arguments, P, generator) {
|
|
38
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
39
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
40
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
41
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
42
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
43
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
48
|
+
var e = new Error(message);
|
|
49
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
50
|
+
};
|
|
51
|
+
|
|
20
52
|
const warnedMessages = new Set();
|
|
21
53
|
function warnDeprecation(message) {
|
|
22
54
|
var _a, _b;
|
|
@@ -33,18 +65,74 @@ const AchievementContext = React.createContext(undefined);
|
|
|
33
65
|
const getAllAchievementRecord = (achievements) => {
|
|
34
66
|
return Object.fromEntries(achievements.map((achievement) => [achievement.achievementId, achievement]));
|
|
35
67
|
};
|
|
36
|
-
const
|
|
68
|
+
const emptySnapshot = () => ({
|
|
69
|
+
metrics: {},
|
|
70
|
+
unlockedIds: [],
|
|
71
|
+
unlockedAchievements: [],
|
|
72
|
+
allAchievements: [],
|
|
73
|
+
unlockedCount: 0,
|
|
74
|
+
totalCount: 0,
|
|
75
|
+
});
|
|
76
|
+
const normalizeAchievementDto = (achievement) => {
|
|
77
|
+
const source = achievement;
|
|
78
|
+
const achievementId = source.id || source.achievementId || '';
|
|
79
|
+
const achievementTitle = source.title || source.achievementTitle || achievementId;
|
|
80
|
+
const achievementDescription = source.description || source.achievementDescription || '';
|
|
81
|
+
const achievementIconKey = source.iconKey || source.icon || source.achievementIconKey;
|
|
82
|
+
return {
|
|
83
|
+
achievementId,
|
|
84
|
+
achievementTitle,
|
|
85
|
+
achievementDescription,
|
|
86
|
+
achievementIconKey,
|
|
87
|
+
confetti: source.confetti,
|
|
88
|
+
isUnlocked: Boolean(source.isUnlocked),
|
|
89
|
+
unlockedAt: source.unlockedAt,
|
|
90
|
+
progress: source.progress,
|
|
91
|
+
metadata: source.metadata,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const normalizeClientSnapshot = (snapshot) => {
|
|
95
|
+
var _a, _b, _c;
|
|
96
|
+
const allAchievements = (snapshot.achievements || []).map(normalizeAchievementDto);
|
|
97
|
+
const unlockedIds = snapshot.unlockedIds ||
|
|
98
|
+
allAchievements
|
|
99
|
+
.filter((achievement) => achievement.isUnlocked)
|
|
100
|
+
.map((achievement) => achievement.achievementId);
|
|
101
|
+
const unlockedIdSet = new Set(unlockedIds);
|
|
102
|
+
const unlockedAchievements = ((_a = snapshot.unlockedAchievements) === null || _a === void 0 ? void 0 : _a.length)
|
|
103
|
+
? snapshot.unlockedAchievements.map(normalizeAchievementDto)
|
|
104
|
+
: allAchievements.filter((achievement) => unlockedIdSet.has(achievement.achievementId));
|
|
105
|
+
return {
|
|
106
|
+
metrics: Object.assign({}, (snapshot.metrics || {})),
|
|
107
|
+
unlockedIds,
|
|
108
|
+
unlockedAchievements,
|
|
109
|
+
allAchievements,
|
|
110
|
+
unlockedCount: (_b = snapshot.unlockedCount) !== null && _b !== void 0 ? _b : unlockedAchievements.length,
|
|
111
|
+
totalCount: (_c = snapshot.totalCount) !== null && _c !== void 0 ? _c : allAchievements.length,
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const toError = (unknownError, fallbackMessage) => (unknownError instanceof Error ? unknownError : new Error(fallbackMessage));
|
|
115
|
+
const AchievementProvider = ({ client, achievements: achievementsConfig, storage = 'local', children, onError, useBuiltInUI, restApiConfig, engine: externalEngine, eventMapping, icons = {}, }) => {
|
|
37
116
|
if (useBuiltInUI !== undefined) {
|
|
38
117
|
warnDeprecation('`useBuiltInUI` is deprecated and is now a no-op because built-in UI is the default. It will be removed in 5.0.');
|
|
39
118
|
}
|
|
119
|
+
if (client && (achievementsConfig || externalEngine)) {
|
|
120
|
+
throw new Error('Cannot provide "client" with "achievements" or "engine" props to AchievementProvider.\n\n' +
|
|
121
|
+
'Use one pattern:\n' +
|
|
122
|
+
'1. Server-backed display: <AchievementProvider client={achievementClient}>\n' +
|
|
123
|
+
'2. Legacy in-browser engine: <AchievementProvider achievements={config}>');
|
|
124
|
+
}
|
|
40
125
|
if (achievementsConfig && externalEngine) {
|
|
41
126
|
throw new Error('Cannot provide both "achievements" and "engine" props to AchievementProvider.\n\n' +
|
|
42
127
|
'Choose one pattern:\n' +
|
|
43
128
|
'1. Direct metric tracking: <AchievementProvider achievements={config}>\n' +
|
|
44
129
|
'2. Event-based tracking: <AchievementProvider engine={myEngine}>');
|
|
45
130
|
}
|
|
46
|
-
const isProviderCreatedEngine = Boolean(achievementsConfig);
|
|
131
|
+
const isProviderCreatedEngine = Boolean(achievementsConfig) && !client;
|
|
47
132
|
const [engine] = React.useState(() => {
|
|
133
|
+
if (client) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
48
136
|
if (externalEngine) {
|
|
49
137
|
return externalEngine;
|
|
50
138
|
}
|
|
@@ -61,18 +149,63 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = 'loca
|
|
|
61
149
|
eventMapping,
|
|
62
150
|
});
|
|
63
151
|
});
|
|
64
|
-
const [achievementSnapshot, setAchievementSnapshot] = React.useState(() => engine.getSnapshot());
|
|
152
|
+
const [achievementSnapshot, setAchievementSnapshot] = React.useState(() => engine ? engine.getSnapshot() : emptySnapshot());
|
|
153
|
+
const [recentlyUnlocked, setRecentlyUnlocked] = React.useState([]);
|
|
154
|
+
const [isLoading, setIsLoading] = React.useState(Boolean(client));
|
|
155
|
+
const [error, setError] = React.useState(null);
|
|
65
156
|
const syncAchievementState = React.useCallback((snapshot) => {
|
|
66
|
-
|
|
157
|
+
if (snapshot) {
|
|
158
|
+
setAchievementSnapshot(snapshot);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (engine) {
|
|
162
|
+
setAchievementSnapshot(engine.getSnapshot());
|
|
163
|
+
}
|
|
67
164
|
}, [engine]);
|
|
165
|
+
const applyClientMutationResult = React.useCallback((result) => {
|
|
166
|
+
setRecentlyUnlocked((result.newlyUnlocked || []).map(normalizeAchievementDto));
|
|
167
|
+
const normalizedSnapshot = normalizeClientSnapshot(result.snapshot);
|
|
168
|
+
setAchievementSnapshot(normalizedSnapshot);
|
|
169
|
+
return result;
|
|
170
|
+
}, []);
|
|
171
|
+
const refresh = React.useCallback(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
172
|
+
if (!client) {
|
|
173
|
+
const snapshot = engine ? engine.getSnapshot() : emptySnapshot();
|
|
174
|
+
setAchievementSnapshot(snapshot);
|
|
175
|
+
return snapshot;
|
|
176
|
+
}
|
|
177
|
+
setIsLoading(true);
|
|
178
|
+
setError(null);
|
|
179
|
+
try {
|
|
180
|
+
const snapshot = normalizeClientSnapshot(yield client.getSnapshot());
|
|
181
|
+
setAchievementSnapshot(snapshot);
|
|
182
|
+
return snapshot;
|
|
183
|
+
}
|
|
184
|
+
catch (unknownError) {
|
|
185
|
+
const nextError = toError(unknownError, 'Failed to load achievements');
|
|
186
|
+
setError(nextError);
|
|
187
|
+
throw nextError;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
setIsLoading(false);
|
|
191
|
+
}
|
|
192
|
+
}), [client, engine]);
|
|
193
|
+
const handleClientMutationFailure = React.useCallback((unknownError) => {
|
|
194
|
+
const nextError = toError(unknownError, 'Failed to update achievements');
|
|
195
|
+
setError(nextError);
|
|
196
|
+
throw nextError;
|
|
197
|
+
}, []);
|
|
68
198
|
React.useEffect(() => {
|
|
69
199
|
return () => {
|
|
70
|
-
if (!externalEngine) {
|
|
200
|
+
if (engine && !externalEngine) {
|
|
71
201
|
engine.destroy();
|
|
72
202
|
}
|
|
73
203
|
};
|
|
74
204
|
}, [engine, externalEngine]);
|
|
75
205
|
React.useEffect(() => {
|
|
206
|
+
if (!engine) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
76
209
|
let isMounted = true;
|
|
77
210
|
const unsubscribeStateChanged = engine.on('state:changed', (event) => {
|
|
78
211
|
syncAchievementState(event);
|
|
@@ -87,29 +220,84 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = 'loca
|
|
|
87
220
|
unsubscribeStateChanged();
|
|
88
221
|
};
|
|
89
222
|
}, [engine, syncAchievementState]);
|
|
223
|
+
React.useEffect(() => {
|
|
224
|
+
if (!client) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
refresh().catch(() => {
|
|
228
|
+
});
|
|
229
|
+
return () => {
|
|
230
|
+
};
|
|
231
|
+
}, [client, refresh]);
|
|
90
232
|
const update = (newMetrics) => {
|
|
91
|
-
|
|
233
|
+
if (client) {
|
|
234
|
+
setError(null);
|
|
235
|
+
const mutation = client.trackMany
|
|
236
|
+
? client.trackMany(newMetrics)
|
|
237
|
+
: Promise.all(Object.entries(newMetrics).map(([metric, value]) => client.track(metric, value))).then((results) => results[results.length - 1]);
|
|
238
|
+
return mutation.then((result) => {
|
|
239
|
+
if (result) {
|
|
240
|
+
applyClientMutationResult(result);
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}).catch(handleClientMutationFailure);
|
|
244
|
+
}
|
|
245
|
+
return engine === null || engine === void 0 ? void 0 : engine.update(newMetrics);
|
|
246
|
+
};
|
|
247
|
+
const increment = (metric, amount = 1) => {
|
|
248
|
+
if (client) {
|
|
249
|
+
setError(null);
|
|
250
|
+
return client.increment(metric, amount)
|
|
251
|
+
.then(applyClientMutationResult)
|
|
252
|
+
.catch(handleClientMutationFailure);
|
|
253
|
+
}
|
|
254
|
+
return engine === null || engine === void 0 ? void 0 : engine.increment(metric, amount);
|
|
255
|
+
};
|
|
256
|
+
const event = (name, payload) => {
|
|
257
|
+
if (client) {
|
|
258
|
+
setError(null);
|
|
259
|
+
return client.event(name, payload)
|
|
260
|
+
.then(applyClientMutationResult)
|
|
261
|
+
.catch(handleClientMutationFailure);
|
|
262
|
+
}
|
|
263
|
+
engine === null || engine === void 0 ? void 0 : engine.emit(name, payload);
|
|
92
264
|
};
|
|
93
265
|
const reset = () => {
|
|
94
|
-
|
|
266
|
+
if (client === null || client === void 0 ? void 0 : client.reset) {
|
|
267
|
+
setError(null);
|
|
268
|
+
return client.reset().then((snapshot) => {
|
|
269
|
+
setRecentlyUnlocked([]);
|
|
270
|
+
setAchievementSnapshot(normalizeClientSnapshot(snapshot));
|
|
271
|
+
}).catch(handleClientMutationFailure);
|
|
272
|
+
}
|
|
273
|
+
if (client) {
|
|
274
|
+
return refresh().then(() => undefined);
|
|
275
|
+
}
|
|
276
|
+
engine === null || engine === void 0 ? void 0 : engine.reset();
|
|
95
277
|
};
|
|
96
278
|
const getState = () => {
|
|
97
|
-
const snapshot = engine.getSnapshot();
|
|
279
|
+
const snapshot = engine ? engine.getSnapshot() : achievementSnapshot;
|
|
98
280
|
return {
|
|
99
281
|
metrics: snapshot.metrics,
|
|
100
282
|
unlocked: snapshot.unlockedIds,
|
|
101
283
|
};
|
|
102
284
|
};
|
|
103
285
|
const exportData = () => {
|
|
104
|
-
return engine.export();
|
|
286
|
+
return engine ? engine.export() : JSON.stringify(achievementSnapshot);
|
|
105
287
|
};
|
|
106
288
|
const importData = (jsonString, options) => {
|
|
289
|
+
if (!engine) {
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
errors: ['importData is not supported by remote achievement clients. Import on the server instead.'],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
107
295
|
const result = engine.import(jsonString, options);
|
|
108
296
|
syncAchievementState();
|
|
109
297
|
return result;
|
|
110
298
|
};
|
|
111
299
|
const getAllAchievements = () => {
|
|
112
|
-
return engine.getSnapshot().allAchievements;
|
|
300
|
+
return engine ? engine.getSnapshot().allAchievements : achievementSnapshot.allAchievements;
|
|
113
301
|
};
|
|
114
302
|
const achievements = {
|
|
115
303
|
unlocked: achievementSnapshot.unlockedIds,
|
|
@@ -117,6 +305,9 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = 'loca
|
|
|
117
305
|
};
|
|
118
306
|
return (React.createElement(AchievementContext.Provider, { value: {
|
|
119
307
|
update,
|
|
308
|
+
increment,
|
|
309
|
+
event,
|
|
310
|
+
refresh,
|
|
120
311
|
achievements,
|
|
121
312
|
snapshot: achievementSnapshot,
|
|
122
313
|
reset,
|
|
@@ -125,7 +316,11 @@ const AchievementProvider = ({ achievements: achievementsConfig, storage = 'loca
|
|
|
125
316
|
importData,
|
|
126
317
|
getAllAchievements,
|
|
127
318
|
engine,
|
|
319
|
+
client,
|
|
128
320
|
icons,
|
|
321
|
+
recentlyUnlocked,
|
|
322
|
+
isLoading,
|
|
323
|
+
error,
|
|
129
324
|
_isLegacyPattern: isProviderCreatedEngine,
|
|
130
325
|
} }, children));
|
|
131
326
|
};
|
|
@@ -155,11 +350,11 @@ const useAchievementState = () => {
|
|
|
155
350
|
* Provides the v4 happy path for direct metric updates plus explicit state names.
|
|
156
351
|
*/
|
|
157
352
|
const useSimpleAchievements = () => {
|
|
158
|
-
const { update, reset, getState, exportData, importData,
|
|
353
|
+
const { update, increment: incrementMetric, event, refresh, reset, getState, exportData, importData, isLoading, error, } = useAchievements();
|
|
159
354
|
const achievementState = useAchievementState();
|
|
160
355
|
const track = (metric, value) => update({ [metric]: value });
|
|
161
356
|
const increment = (metric, amount = 1) => {
|
|
162
|
-
return
|
|
357
|
+
return incrementMetric(metric, amount);
|
|
163
358
|
};
|
|
164
359
|
const trackMultiple = (metrics) => update(metrics);
|
|
165
360
|
return {
|
|
@@ -172,6 +367,11 @@ const useSimpleAchievements = () => {
|
|
|
172
367
|
unlockedCount: achievementState.unlockedCount,
|
|
173
368
|
totalCount: achievementState.totalCount,
|
|
174
369
|
metrics: achievementState.metrics,
|
|
370
|
+
isLoading,
|
|
371
|
+
error,
|
|
372
|
+
event,
|
|
373
|
+
emit: event,
|
|
374
|
+
refresh,
|
|
175
375
|
reset,
|
|
176
376
|
getState,
|
|
177
377
|
exportData,
|
|
@@ -203,9 +403,95 @@ const useAchievementEngine = () => {
|
|
|
203
403
|
' <YourComponent />\n' +
|
|
204
404
|
'</AchievementProvider>');
|
|
205
405
|
}
|
|
406
|
+
if (!context.engine) {
|
|
407
|
+
throw new Error('useAchievementEngine is only available when AchievementProvider is using the legacy in-browser engine. ' +
|
|
408
|
+
'For server-backed achievements, use useSimpleAchievements().event(), track(), or increment().');
|
|
409
|
+
}
|
|
206
410
|
return context.engine;
|
|
207
411
|
};
|
|
208
412
|
|
|
413
|
+
const trimTrailingSlash = (value) => value.replace(/\/$/, '');
|
|
414
|
+
class RestAchievementClient {
|
|
415
|
+
constructor(config) {
|
|
416
|
+
this.baseUrl = trimTrailingSlash(config.baseUrl);
|
|
417
|
+
this.fetcher = config.fetcher || fetch.bind(globalThis);
|
|
418
|
+
this.headers = config.headers;
|
|
419
|
+
this.credentials = config.credentials;
|
|
420
|
+
this.timeout = config.timeout;
|
|
421
|
+
}
|
|
422
|
+
getSnapshot() {
|
|
423
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
424
|
+
return this.request('', { method: 'GET' });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
track(metric, value) {
|
|
428
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
429
|
+
return this.request('/track', {
|
|
430
|
+
method: 'POST',
|
|
431
|
+
body: JSON.stringify({ metric, value }),
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
trackMany(metrics) {
|
|
436
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
437
|
+
return this.request('/track', {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
body: JSON.stringify({ metrics }),
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
increment(metric_1) {
|
|
444
|
+
return __awaiter(this, arguments, void 0, function* (metric, amount = 1) {
|
|
445
|
+
return this.request('/increment', {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
body: JSON.stringify({ metric, amount }),
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
event(name, payload) {
|
|
452
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
453
|
+
return this.request('/event', {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
body: JSON.stringify({ name, payload }),
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
reset() {
|
|
460
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
461
|
+
return this.request('/reset', { method: 'POST' });
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
request(path, init) {
|
|
465
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
466
|
+
const controller = this.timeout ? new AbortController() : undefined;
|
|
467
|
+
const timeoutId = controller
|
|
468
|
+
? setTimeout(() => controller.abort(), this.timeout)
|
|
469
|
+
: undefined;
|
|
470
|
+
try {
|
|
471
|
+
const response = yield this.fetcher(`${this.baseUrl}${path}`, Object.assign(Object.assign({}, init), { credentials: this.credentials, headers: Object.assign(Object.assign({ 'Content-Type': 'application/json' }, (yield this.resolveHeaders())), init.headers), signal: controller === null || controller === void 0 ? void 0 : controller.signal }));
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
throw new Error(`Achievement request failed: HTTP ${response.status}`);
|
|
474
|
+
}
|
|
475
|
+
return yield response.json();
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
if (timeoutId) {
|
|
479
|
+
clearTimeout(timeoutId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
resolveHeaders() {
|
|
485
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
486
|
+
if (!this.headers) {
|
|
487
|
+
return {};
|
|
488
|
+
}
|
|
489
|
+
return typeof this.headers === 'function' ? this.headers() : this.headers;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const createRestAchievementClient = (config) => new RestAchievementClient(config);
|
|
494
|
+
|
|
209
495
|
Object.defineProperty(exports, 'AchievementBuilder', {
|
|
210
496
|
enumerable: true,
|
|
211
497
|
get: function () { return achievementsEngine.AchievementBuilder; }
|
|
@@ -296,6 +582,8 @@ Object.defineProperty(exports, 'normalizeAchievements', {
|
|
|
296
582
|
});
|
|
297
583
|
exports.AchievementContext = AchievementContext;
|
|
298
584
|
exports.AchievementProvider = AchievementProvider;
|
|
585
|
+
exports.RestAchievementClient = RestAchievementClient;
|
|
586
|
+
exports.createRestAchievementClient = createRestAchievementClient;
|
|
299
587
|
exports.isAsyncStorage = isAsyncStorage;
|
|
300
588
|
exports.useAchievementEngine = useAchievementEngine;
|
|
301
589
|
exports.useAchievementState = useAchievementState;
|