@lessonkit/react 0.4.0 → 0.6.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 +17 -10
- package/dist/index.cjs +400 -112
- package/dist/index.d.cts +28 -15
- package/dist/index.d.ts +28 -15
- package/dist/index.js +396 -105
- package/package.json +8 -7
package/dist/index.js
CHANGED
|
@@ -12,8 +12,156 @@ import {
|
|
|
12
12
|
useRef,
|
|
13
13
|
useState
|
|
14
14
|
} from "react";
|
|
15
|
-
import { createTrackingClient
|
|
15
|
+
import { createTrackingClient } from "@lessonkit/core";
|
|
16
16
|
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
17
|
+
import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
|
|
18
|
+
|
|
19
|
+
// src/runtime/emitTelemetry.ts
|
|
20
|
+
import { nowIso } from "@lessonkit/core";
|
|
21
|
+
import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
22
|
+
|
|
23
|
+
// src/runtime/lxpackBridge.ts
|
|
24
|
+
import {
|
|
25
|
+
normalizeAssessmentPassingScore,
|
|
26
|
+
normalizeAssessmentScore
|
|
27
|
+
} from "@lessonkit/lxpack/bridge";
|
|
28
|
+
function getBridge() {
|
|
29
|
+
if (typeof window === "undefined") return null;
|
|
30
|
+
const parent = window.parent;
|
|
31
|
+
if (!parent || parent === window) return null;
|
|
32
|
+
return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
|
|
33
|
+
}
|
|
34
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
35
|
+
if (mode === "off") return;
|
|
36
|
+
const bridge = getBridge();
|
|
37
|
+
if (!bridge) return;
|
|
38
|
+
switch (event.name) {
|
|
39
|
+
case "lesson_completed": {
|
|
40
|
+
const lessonId = event.lessonId;
|
|
41
|
+
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
case "course_completed":
|
|
45
|
+
bridge.completeCourse?.();
|
|
46
|
+
return;
|
|
47
|
+
case "quiz_completed": {
|
|
48
|
+
const data = event.data;
|
|
49
|
+
if (!data?.checkId) return;
|
|
50
|
+
const scaled = normalizeAssessmentScore({
|
|
51
|
+
score: data.score,
|
|
52
|
+
maxScore: data.maxScore
|
|
53
|
+
});
|
|
54
|
+
if (scaled === null) return;
|
|
55
|
+
bridge.submitAssessment?.({
|
|
56
|
+
id: data.checkId,
|
|
57
|
+
score: scaled,
|
|
58
|
+
passingScore: normalizeAssessmentPassingScore(data.passingScore)
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/runtime/emitTelemetry.ts
|
|
68
|
+
var warnedMissingCourseId = false;
|
|
69
|
+
var warnedMissingQuizLesson = false;
|
|
70
|
+
function isDevEnvironment() {
|
|
71
|
+
const g = globalThis;
|
|
72
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
73
|
+
}
|
|
74
|
+
function emitTelemetry(tracking, xapi, event, opts) {
|
|
75
|
+
if (!event.courseId) {
|
|
76
|
+
if (isDevEnvironment() && !warnedMissingCourseId) {
|
|
77
|
+
warnedMissingCourseId = true;
|
|
78
|
+
console.warn("[lessonkit] telemetry event missing courseId");
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
tracking.track(event);
|
|
83
|
+
try {
|
|
84
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
85
|
+
if (statement) xapi?.send(statement);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (isDevEnvironment()) {
|
|
88
|
+
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
|
|
92
|
+
}
|
|
93
|
+
function buildTrackEvent(opts) {
|
|
94
|
+
const base = {
|
|
95
|
+
timestamp: nowIso(),
|
|
96
|
+
courseId: opts.courseId,
|
|
97
|
+
sessionId: opts.sessionId,
|
|
98
|
+
attemptId: opts.attemptId,
|
|
99
|
+
user: opts.user
|
|
100
|
+
};
|
|
101
|
+
switch (opts.name) {
|
|
102
|
+
case "course_started":
|
|
103
|
+
return { name: "course_started", ...base };
|
|
104
|
+
case "course_completed":
|
|
105
|
+
return { name: "course_completed", ...base };
|
|
106
|
+
case "lesson_started": {
|
|
107
|
+
const data = opts.data;
|
|
108
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
109
|
+
if (!lessonId) throw new Error("lesson_started requires lessonId");
|
|
110
|
+
return {
|
|
111
|
+
name: "lesson_started",
|
|
112
|
+
...base,
|
|
113
|
+
lessonId,
|
|
114
|
+
data: { ...data, lessonId }
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
case "lesson_completed":
|
|
118
|
+
case "lesson_time_on_task": {
|
|
119
|
+
const data = opts.data;
|
|
120
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
121
|
+
if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
|
|
122
|
+
return {
|
|
123
|
+
name: opts.name,
|
|
124
|
+
...base,
|
|
125
|
+
lessonId,
|
|
126
|
+
data: { ...data, lessonId }
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
case "quiz_answered": {
|
|
130
|
+
const data = opts.data;
|
|
131
|
+
const lessonId = opts.lessonId;
|
|
132
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
133
|
+
return { name: "quiz_answered", ...base, lessonId, data };
|
|
134
|
+
}
|
|
135
|
+
case "quiz_completed": {
|
|
136
|
+
const data = opts.data;
|
|
137
|
+
const lessonId = opts.lessonId;
|
|
138
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
139
|
+
return { name: "quiz_completed", ...base, lessonId, data };
|
|
140
|
+
}
|
|
141
|
+
case "interaction":
|
|
142
|
+
return {
|
|
143
|
+
name: "interaction",
|
|
144
|
+
...base,
|
|
145
|
+
lessonId: opts.lessonId,
|
|
146
|
+
data: opts.data
|
|
147
|
+
};
|
|
148
|
+
default:
|
|
149
|
+
return { name: opts.name, ...base };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function tryBuildTrackEvent(opts) {
|
|
153
|
+
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
154
|
+
if (isQuiz && !opts.lessonId) {
|
|
155
|
+
if (isDevEnvironment() && !warnedMissingQuizLesson) {
|
|
156
|
+
warnedMissingQuizLesson = true;
|
|
157
|
+
console.warn(
|
|
158
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return buildTrackEvent(opts);
|
|
164
|
+
}
|
|
17
165
|
|
|
18
166
|
// src/runtime/ports.ts
|
|
19
167
|
function createNoopStorage() {
|
|
@@ -42,13 +190,51 @@ function createSessionStoragePort() {
|
|
|
42
190
|
};
|
|
43
191
|
}
|
|
44
192
|
|
|
193
|
+
// src/runtime/progress.ts
|
|
194
|
+
function createProgressController() {
|
|
195
|
+
let activeLessonId;
|
|
196
|
+
let completedLessonIds = /* @__PURE__ */ new Set();
|
|
197
|
+
let courseCompleted = false;
|
|
198
|
+
const lessonStartTimes = /* @__PURE__ */ new Map();
|
|
199
|
+
return {
|
|
200
|
+
getState: () => ({
|
|
201
|
+
activeLessonId,
|
|
202
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
203
|
+
courseCompleted
|
|
204
|
+
}),
|
|
205
|
+
setActiveLesson: (lessonId, startedAtMs) => {
|
|
206
|
+
const previousLessonId = activeLessonId;
|
|
207
|
+
activeLessonId = lessonId;
|
|
208
|
+
lessonStartTimes.set(lessonId, startedAtMs);
|
|
209
|
+
return { previousLessonId };
|
|
210
|
+
},
|
|
211
|
+
completeLesson: (lessonId, completedAtMs) => {
|
|
212
|
+
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
213
|
+
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
214
|
+
const startedAt = lessonStartTimes.get(lessonId);
|
|
215
|
+
lessonStartTimes.delete(lessonId);
|
|
216
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
217
|
+
return { durationMs, didComplete: true };
|
|
218
|
+
},
|
|
219
|
+
completeCourse: () => {
|
|
220
|
+
if (courseCompleted) return { didComplete: false };
|
|
221
|
+
courseCompleted = true;
|
|
222
|
+
return { didComplete: true };
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
45
227
|
// src/runtime/xapi.ts
|
|
46
228
|
import { createXAPIClient } from "@lessonkit/xapi";
|
|
47
229
|
function createXapiClientFromConfig(config, queue) {
|
|
48
230
|
if (config.xapi?.enabled === false) return null;
|
|
49
231
|
if (config.xapi?.client) return config.xapi.client;
|
|
50
|
-
|
|
51
|
-
return createXAPIClient({
|
|
232
|
+
if (!config.courseId) return null;
|
|
233
|
+
return createXAPIClient({
|
|
234
|
+
courseId: config.courseId,
|
|
235
|
+
transport: config.xapi?.transport,
|
|
236
|
+
queue
|
|
237
|
+
});
|
|
52
238
|
}
|
|
53
239
|
|
|
54
240
|
// src/runtime/session.ts
|
|
@@ -95,7 +281,7 @@ function createTrackingClientFromConfig(config) {
|
|
|
95
281
|
});
|
|
96
282
|
}
|
|
97
283
|
function LessonkitProvider(props) {
|
|
98
|
-
const config = props.config
|
|
284
|
+
const config = props.config;
|
|
99
285
|
const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
|
|
100
286
|
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
101
287
|
const attemptIdRef = useRef(config.session?.attemptId);
|
|
@@ -104,49 +290,15 @@ function LessonkitProvider(props) {
|
|
|
104
290
|
userRef.current = config.session?.user;
|
|
105
291
|
const courseIdRef = useRef(config.courseId);
|
|
106
292
|
courseIdRef.current = config.courseId;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
useIsoLayoutEffect(() => {
|
|
117
|
-
const prev = trackingRef.current;
|
|
118
|
-
const next = createTrackingClientFromConfig(config);
|
|
119
|
-
trackingRef.current = next;
|
|
120
|
-
setTracking(next);
|
|
121
|
-
const sessionId = sessionIdRef.current;
|
|
122
|
-
const cid = courseIdRef.current;
|
|
123
|
-
const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
|
|
124
|
-
if (shouldEmitCourseStarted) {
|
|
125
|
-
if (cid) {
|
|
126
|
-
markCourseStarted(defaultStorage, sessionId, cid);
|
|
127
|
-
} else {
|
|
128
|
-
courseStartedInProviderRef.current = true;
|
|
129
|
-
}
|
|
130
|
-
next.track({
|
|
131
|
-
name: "course_started",
|
|
132
|
-
timestamp: nowIso(),
|
|
133
|
-
courseId: cid,
|
|
134
|
-
sessionId,
|
|
135
|
-
attemptId: attemptIdRef.current,
|
|
136
|
-
user: userRef.current
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return () => {
|
|
140
|
-
disposeTrackingClient(prev);
|
|
141
|
-
};
|
|
142
|
-
}, [
|
|
143
|
-
trackingEnabled,
|
|
144
|
-
trackingSink,
|
|
145
|
-
trackingBatchSink,
|
|
146
|
-
batchEnabled,
|
|
147
|
-
batchFlushIntervalMs,
|
|
148
|
-
batchMaxBatchSize
|
|
149
|
-
]);
|
|
293
|
+
const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
|
|
294
|
+
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
295
|
+
const progressRef = useRef(createProgressController());
|
|
296
|
+
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
297
|
+
const syncProgress = useCallback(() => {
|
|
298
|
+
setProgress(progressRef.current.getState());
|
|
299
|
+
}, []);
|
|
300
|
+
const activeLessonIdRef = useRef(progress.activeLessonId);
|
|
301
|
+
activeLessonIdRef.current = progress.activeLessonId;
|
|
150
302
|
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
151
303
|
const xapiRef = useRef(null);
|
|
152
304
|
const [xapi, setXapi] = useState(null);
|
|
@@ -159,6 +311,25 @@ function LessonkitProvider(props) {
|
|
|
159
311
|
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
160
312
|
xapiRef.current = next;
|
|
161
313
|
setXapi(next);
|
|
314
|
+
if (next && !prev) {
|
|
315
|
+
const sessionId = sessionIdRef.current;
|
|
316
|
+
const cid = courseIdRef.current;
|
|
317
|
+
if (hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
318
|
+
try {
|
|
319
|
+
const statement = telemetryEventToXAPIStatement2(
|
|
320
|
+
buildTrackEvent({
|
|
321
|
+
name: "course_started",
|
|
322
|
+
courseId: cid,
|
|
323
|
+
sessionId,
|
|
324
|
+
attemptId: attemptIdRef.current,
|
|
325
|
+
user: userRef.current
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
if (statement) next.send(statement);
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
162
333
|
void (async () => {
|
|
163
334
|
if (prev) {
|
|
164
335
|
try {
|
|
@@ -175,21 +346,59 @@ function LessonkitProvider(props) {
|
|
|
175
346
|
void prev?.flush();
|
|
176
347
|
};
|
|
177
348
|
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
349
|
+
const trackingRef = useRef(createTrackingClient());
|
|
350
|
+
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
351
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
352
|
+
const trackingSink = config.tracking?.sink;
|
|
353
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
354
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
355
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
356
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
357
|
+
useIsoLayoutEffect(() => {
|
|
358
|
+
const prev = trackingRef.current;
|
|
359
|
+
const next = createTrackingClientFromConfig(config);
|
|
360
|
+
trackingRef.current = next;
|
|
361
|
+
setTracking(next);
|
|
362
|
+
const sessionId = sessionIdRef.current;
|
|
363
|
+
const cid = courseIdRef.current;
|
|
364
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
365
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
366
|
+
emitTelemetry(
|
|
367
|
+
next,
|
|
368
|
+
xapiRef.current,
|
|
369
|
+
buildTrackEvent({
|
|
370
|
+
name: "course_started",
|
|
371
|
+
courseId: cid,
|
|
372
|
+
sessionId,
|
|
373
|
+
attemptId: attemptIdRef.current,
|
|
374
|
+
user: userRef.current
|
|
375
|
+
}),
|
|
376
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
return () => {
|
|
380
|
+
disposeTrackingClient(prev);
|
|
381
|
+
};
|
|
382
|
+
}, [
|
|
383
|
+
trackingEnabled,
|
|
384
|
+
trackingSink,
|
|
385
|
+
trackingBatchSink,
|
|
386
|
+
batchEnabled,
|
|
387
|
+
batchFlushIntervalMs,
|
|
388
|
+
batchMaxBatchSize
|
|
389
|
+
]);
|
|
390
|
+
const emitWithBridge = useCallback(
|
|
391
|
+
(trackingClient, event) => {
|
|
392
|
+
emitTelemetry(trackingClient, xapiRef.current, event, {
|
|
393
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
[]
|
|
397
|
+
);
|
|
188
398
|
const track = useCallback(
|
|
189
399
|
(name, data, opts) => {
|
|
190
|
-
|
|
400
|
+
const event = tryBuildTrackEvent({
|
|
191
401
|
name,
|
|
192
|
-
timestamp: nowIso(),
|
|
193
402
|
courseId: courseIdRef.current,
|
|
194
403
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
195
404
|
sessionId: sessionIdRef.current,
|
|
@@ -197,54 +406,85 @@ function LessonkitProvider(props) {
|
|
|
197
406
|
user: userRef.current,
|
|
198
407
|
data
|
|
199
408
|
});
|
|
409
|
+
if (!event) return;
|
|
410
|
+
emitWithBridge(trackingRef.current, event);
|
|
200
411
|
},
|
|
201
|
-
[]
|
|
412
|
+
[emitWithBridge]
|
|
202
413
|
);
|
|
414
|
+
const prevCourseIdRef = useRef(config.courseId);
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
if (prevCourseIdRef.current === config.courseId) return;
|
|
417
|
+
prevCourseIdRef.current = config.courseId;
|
|
418
|
+
progressRef.current = createProgressController();
|
|
419
|
+
syncProgress();
|
|
420
|
+
const sessionId = sessionIdRef.current;
|
|
421
|
+
const cid = config.courseId;
|
|
422
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
423
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
424
|
+
emitTelemetry(
|
|
425
|
+
trackingRef.current,
|
|
426
|
+
xapiRef.current,
|
|
427
|
+
buildTrackEvent({
|
|
428
|
+
name: "course_started",
|
|
429
|
+
courseId: cid,
|
|
430
|
+
sessionId,
|
|
431
|
+
attemptId: attemptIdRef.current,
|
|
432
|
+
user: userRef.current
|
|
433
|
+
}),
|
|
434
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}, [config.courseId, syncProgress]);
|
|
203
438
|
useEffect(() => {
|
|
204
439
|
return () => {
|
|
205
440
|
trackingRef.current?.flush?.();
|
|
206
441
|
void xapiRef.current?.flush();
|
|
207
442
|
};
|
|
208
443
|
}, []);
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
activeLessonIdRef.current = lessonId;
|
|
212
|
-
setActiveLessonId(lessonId);
|
|
213
|
-
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
214
|
-
track("lesson_started", { lessonId }, { lessonId });
|
|
215
|
-
xapiRef.current?.startedLesson({ lessonId });
|
|
216
|
-
}, [track]);
|
|
217
|
-
const completeLesson = useCallback(
|
|
218
|
-
(lessonId) => {
|
|
219
|
-
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
220
|
-
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
221
|
-
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
222
|
-
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
223
|
-
lessonStartTimesRef.current.delete(lessonId);
|
|
224
|
-
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
444
|
+
const emitLessonCompleted = useCallback(
|
|
445
|
+
(lessonId, durationMs) => {
|
|
225
446
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
226
447
|
if (durationMs !== void 0) {
|
|
227
448
|
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
228
449
|
}
|
|
229
|
-
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
230
450
|
},
|
|
231
451
|
[track]
|
|
232
452
|
);
|
|
453
|
+
const completeLesson = useCallback(
|
|
454
|
+
(lessonId) => {
|
|
455
|
+
const result = progressRef.current.completeLesson(lessonId, Date.now());
|
|
456
|
+
if (!result.didComplete) return;
|
|
457
|
+
syncProgress();
|
|
458
|
+
emitLessonCompleted(lessonId, result.durationMs);
|
|
459
|
+
},
|
|
460
|
+
[syncProgress, emitLessonCompleted]
|
|
461
|
+
);
|
|
462
|
+
const setActiveLesson = useCallback(
|
|
463
|
+
(lessonId) => {
|
|
464
|
+
const current = progressRef.current.getState();
|
|
465
|
+
if (current.activeLessonId === lessonId) return;
|
|
466
|
+
const previous = current.activeLessonId;
|
|
467
|
+
if (previous && previous !== lessonId) {
|
|
468
|
+
const completed = progressRef.current.completeLesson(previous, Date.now());
|
|
469
|
+
if (completed.didComplete) {
|
|
470
|
+
emitLessonCompleted(previous, completed.durationMs);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
474
|
+
syncProgress();
|
|
475
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
476
|
+
},
|
|
477
|
+
[track, syncProgress, emitLessonCompleted]
|
|
478
|
+
);
|
|
233
479
|
const completeCourse = useCallback(() => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
480
|
+
const result = progressRef.current.completeCourse();
|
|
481
|
+
if (!result.didComplete) return;
|
|
482
|
+
syncProgress();
|
|
237
483
|
track("course_completed");
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
activeLessonId,
|
|
243
|
-
completedLessonIds: new Set(completedLessonIds),
|
|
244
|
-
courseCompleted
|
|
245
|
-
}),
|
|
246
|
-
[activeLessonId, completedLessonIds, courseCompleted]
|
|
247
|
-
);
|
|
484
|
+
}, [track, syncProgress]);
|
|
485
|
+
const sessionUser = config.session?.user;
|
|
486
|
+
const sessionAttemptId = config.session?.attemptId;
|
|
487
|
+
const sessionConfiguredId = config.session?.sessionId;
|
|
248
488
|
const runtime = useMemo(
|
|
249
489
|
() => ({
|
|
250
490
|
config,
|
|
@@ -257,7 +497,19 @@ function LessonkitProvider(props) {
|
|
|
257
497
|
completeCourse,
|
|
258
498
|
track
|
|
259
499
|
}),
|
|
260
|
-
[
|
|
500
|
+
[
|
|
501
|
+
config,
|
|
502
|
+
tracking,
|
|
503
|
+
xapi,
|
|
504
|
+
progress,
|
|
505
|
+
setActiveLesson,
|
|
506
|
+
completeLesson,
|
|
507
|
+
completeCourse,
|
|
508
|
+
track,
|
|
509
|
+
sessionUser,
|
|
510
|
+
sessionAttemptId,
|
|
511
|
+
sessionConfiguredId
|
|
512
|
+
]
|
|
261
513
|
);
|
|
262
514
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
263
515
|
}
|
|
@@ -296,9 +548,28 @@ function useQuizState() {
|
|
|
296
548
|
);
|
|
297
549
|
}
|
|
298
550
|
|
|
551
|
+
// src/runtime/validateComponentId.ts
|
|
552
|
+
import { validateId } from "@lessonkit/core";
|
|
553
|
+
var warnedPaths = /* @__PURE__ */ new Set();
|
|
554
|
+
function isDevEnvironment2() {
|
|
555
|
+
const g = globalThis;
|
|
556
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
557
|
+
}
|
|
558
|
+
function warnInvalidComponentId(id, path) {
|
|
559
|
+
if (!isDevEnvironment2()) return;
|
|
560
|
+
const key = `${path}:${String(id)}`;
|
|
561
|
+
if (warnedPaths.has(key)) return;
|
|
562
|
+
const result = validateId(id, path);
|
|
563
|
+
if (result.ok) return;
|
|
564
|
+
warnedPaths.add(key);
|
|
565
|
+
const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
566
|
+
console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
299
569
|
// src/components.tsx
|
|
300
570
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
301
571
|
function Course(props) {
|
|
572
|
+
warnInvalidComponentId(props.courseId, "courseId");
|
|
302
573
|
const providerConfig = useMemo3(
|
|
303
574
|
() => ({ ...props.config, courseId: props.courseId }),
|
|
304
575
|
[props.config, props.courseId]
|
|
@@ -309,15 +580,23 @@ function Course(props) {
|
|
|
309
580
|
] }) });
|
|
310
581
|
}
|
|
311
582
|
function Lesson(props) {
|
|
583
|
+
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
312
584
|
const { setActiveLesson } = useLessonkit();
|
|
313
585
|
const { completeLesson } = useCompletion();
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const id = props.lessonId ?? generatedId;
|
|
586
|
+
const id = props.lessonId;
|
|
587
|
+
const pendingCompleteRef = useRef2(null);
|
|
317
588
|
useEffect2(() => {
|
|
589
|
+
if (pendingCompleteRef.current !== null) {
|
|
590
|
+
clearTimeout(pendingCompleteRef.current);
|
|
591
|
+
pendingCompleteRef.current = null;
|
|
592
|
+
}
|
|
318
593
|
setActiveLesson(id);
|
|
319
594
|
return () => {
|
|
320
|
-
|
|
595
|
+
const lessonId = id;
|
|
596
|
+
pendingCompleteRef.current = setTimeout(() => {
|
|
597
|
+
pendingCompleteRef.current = null;
|
|
598
|
+
completeLesson(lessonId);
|
|
599
|
+
}, 0);
|
|
321
600
|
};
|
|
322
601
|
}, [id, setActiveLesson, completeLesson]);
|
|
323
602
|
return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
@@ -326,11 +605,13 @@ function Lesson(props) {
|
|
|
326
605
|
] });
|
|
327
606
|
}
|
|
328
607
|
function Scenario(props) {
|
|
329
|
-
|
|
608
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
609
|
+
return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
|
|
330
610
|
}
|
|
331
611
|
function Reflection(props) {
|
|
612
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
332
613
|
const promptId = useId();
|
|
333
|
-
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
|
|
614
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
|
|
334
615
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
335
616
|
props.children,
|
|
336
617
|
/* @__PURE__ */ jsx2(
|
|
@@ -343,14 +624,23 @@ function Reflection(props) {
|
|
|
343
624
|
] });
|
|
344
625
|
}
|
|
345
626
|
function KnowledgeCheck(props) {
|
|
346
|
-
return /* @__PURE__ */ jsx2(
|
|
627
|
+
return /* @__PURE__ */ jsx2(
|
|
628
|
+
Quiz,
|
|
629
|
+
{
|
|
630
|
+
checkId: props.checkId,
|
|
631
|
+
question: props.question,
|
|
632
|
+
choices: props.choices,
|
|
633
|
+
answer: props.answer
|
|
634
|
+
}
|
|
635
|
+
);
|
|
347
636
|
}
|
|
348
637
|
function Quiz(props) {
|
|
638
|
+
warnInvalidComponentId(props.checkId, "checkId");
|
|
349
639
|
const quiz = useQuizState();
|
|
350
640
|
const [selected, setSelected] = useState2(null);
|
|
351
641
|
const completedRef = useRef2(false);
|
|
352
642
|
const questionId = useId();
|
|
353
|
-
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
|
|
643
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
|
|
354
644
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
355
645
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
356
646
|
/* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
|
|
@@ -365,10 +655,15 @@ function Quiz(props) {
|
|
|
365
655
|
onChange: () => {
|
|
366
656
|
setSelected(c);
|
|
367
657
|
const correct = c === props.answer;
|
|
368
|
-
quiz.answer({
|
|
658
|
+
quiz.answer({
|
|
659
|
+
checkId: props.checkId,
|
|
660
|
+
question: props.question,
|
|
661
|
+
choice: c,
|
|
662
|
+
correct
|
|
663
|
+
});
|
|
369
664
|
if (correct && !completedRef.current) {
|
|
370
665
|
completedRef.current = true;
|
|
371
|
-
quiz.complete({ score: 1, maxScore: 1 });
|
|
666
|
+
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
|
|
372
667
|
}
|
|
373
668
|
}
|
|
374
669
|
}
|
|
@@ -387,10 +682,6 @@ function ProgressTracker() {
|
|
|
387
682
|
completed
|
|
388
683
|
] }) });
|
|
389
684
|
}
|
|
390
|
-
function sanitizeLessonId(id) {
|
|
391
|
-
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
392
|
-
return s.length ? s : "id";
|
|
393
|
-
}
|
|
394
685
|
|
|
395
686
|
// src/theme/ThemeProvider.tsx
|
|
396
687
|
import React3, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
41
|
-
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
40
|
+
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
|
|
41
|
+
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.json",
|
|
44
44
|
"test": "vitest run --passWithNoTests",
|
|
@@ -50,10 +50,11 @@
|
|
|
50
50
|
"react-dom": ">=18"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@lessonkit/accessibility": "0.
|
|
54
|
-
"@lessonkit/core": "0.
|
|
55
|
-
"@lessonkit/
|
|
56
|
-
"@lessonkit/
|
|
53
|
+
"@lessonkit/accessibility": "0.6.0",
|
|
54
|
+
"@lessonkit/core": "0.6.0",
|
|
55
|
+
"@lessonkit/lxpack": "0.6.0",
|
|
56
|
+
"@lessonkit/themes": "0.6.0",
|
|
57
|
+
"@lessonkit/xapi": "0.6.0"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@testing-library/react": "^16.3.0",
|