@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.cjs
CHANGED
|
@@ -54,8 +54,153 @@ var import_accessibility = require("@lessonkit/accessibility");
|
|
|
54
54
|
|
|
55
55
|
// src/context.tsx
|
|
56
56
|
var import_react = require("react");
|
|
57
|
-
var
|
|
58
|
-
var
|
|
57
|
+
var import_core3 = require("@lessonkit/core");
|
|
58
|
+
var import_xapi3 = require("@lessonkit/xapi");
|
|
59
|
+
var import_xapi4 = require("@lessonkit/xapi");
|
|
60
|
+
|
|
61
|
+
// src/runtime/emitTelemetry.ts
|
|
62
|
+
var import_core = require("@lessonkit/core");
|
|
63
|
+
var import_xapi = require("@lessonkit/xapi");
|
|
64
|
+
|
|
65
|
+
// src/runtime/lxpackBridge.ts
|
|
66
|
+
var import_bridge = require("@lessonkit/lxpack/bridge");
|
|
67
|
+
function getBridge() {
|
|
68
|
+
if (typeof window === "undefined") return null;
|
|
69
|
+
const parent = window.parent;
|
|
70
|
+
if (!parent || parent === window) return null;
|
|
71
|
+
return parent.lxpackBridge?.v1 ?? parent.lxpack ?? null;
|
|
72
|
+
}
|
|
73
|
+
function forwardTelemetryToLxpack(event, mode = "auto") {
|
|
74
|
+
if (mode === "off") return;
|
|
75
|
+
const bridge = getBridge();
|
|
76
|
+
if (!bridge) return;
|
|
77
|
+
switch (event.name) {
|
|
78
|
+
case "lesson_completed": {
|
|
79
|
+
const lessonId = event.lessonId;
|
|
80
|
+
if (lessonId) bridge.completeLesson?.(lessonId);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "course_completed":
|
|
84
|
+
bridge.completeCourse?.();
|
|
85
|
+
return;
|
|
86
|
+
case "quiz_completed": {
|
|
87
|
+
const data = event.data;
|
|
88
|
+
if (!data?.checkId) return;
|
|
89
|
+
const scaled = (0, import_bridge.normalizeAssessmentScore)({
|
|
90
|
+
score: data.score,
|
|
91
|
+
maxScore: data.maxScore
|
|
92
|
+
});
|
|
93
|
+
if (scaled === null) return;
|
|
94
|
+
bridge.submitAssessment?.({
|
|
95
|
+
id: data.checkId,
|
|
96
|
+
score: scaled,
|
|
97
|
+
passingScore: (0, import_bridge.normalizeAssessmentPassingScore)(data.passingScore)
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/runtime/emitTelemetry.ts
|
|
107
|
+
var warnedMissingCourseId = false;
|
|
108
|
+
var warnedMissingQuizLesson = false;
|
|
109
|
+
function isDevEnvironment() {
|
|
110
|
+
const g = globalThis;
|
|
111
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
112
|
+
}
|
|
113
|
+
function emitTelemetry(tracking, xapi, event, opts) {
|
|
114
|
+
if (!event.courseId) {
|
|
115
|
+
if (isDevEnvironment() && !warnedMissingCourseId) {
|
|
116
|
+
warnedMissingCourseId = true;
|
|
117
|
+
console.warn("[lessonkit] telemetry event missing courseId");
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
tracking.track(event);
|
|
122
|
+
try {
|
|
123
|
+
const statement = (0, import_xapi.telemetryEventToXAPIStatement)(event);
|
|
124
|
+
if (statement) xapi?.send(statement);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (isDevEnvironment()) {
|
|
127
|
+
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
|
|
131
|
+
}
|
|
132
|
+
function buildTrackEvent(opts) {
|
|
133
|
+
const base = {
|
|
134
|
+
timestamp: (0, import_core.nowIso)(),
|
|
135
|
+
courseId: opts.courseId,
|
|
136
|
+
sessionId: opts.sessionId,
|
|
137
|
+
attemptId: opts.attemptId,
|
|
138
|
+
user: opts.user
|
|
139
|
+
};
|
|
140
|
+
switch (opts.name) {
|
|
141
|
+
case "course_started":
|
|
142
|
+
return { name: "course_started", ...base };
|
|
143
|
+
case "course_completed":
|
|
144
|
+
return { name: "course_completed", ...base };
|
|
145
|
+
case "lesson_started": {
|
|
146
|
+
const data = opts.data;
|
|
147
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
148
|
+
if (!lessonId) throw new Error("lesson_started requires lessonId");
|
|
149
|
+
return {
|
|
150
|
+
name: "lesson_started",
|
|
151
|
+
...base,
|
|
152
|
+
lessonId,
|
|
153
|
+
data: { ...data, lessonId }
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
case "lesson_completed":
|
|
157
|
+
case "lesson_time_on_task": {
|
|
158
|
+
const data = opts.data;
|
|
159
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
160
|
+
if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
|
|
161
|
+
return {
|
|
162
|
+
name: opts.name,
|
|
163
|
+
...base,
|
|
164
|
+
lessonId,
|
|
165
|
+
data: { ...data, lessonId }
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
case "quiz_answered": {
|
|
169
|
+
const data = opts.data;
|
|
170
|
+
const lessonId = opts.lessonId;
|
|
171
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
172
|
+
return { name: "quiz_answered", ...base, lessonId, data };
|
|
173
|
+
}
|
|
174
|
+
case "quiz_completed": {
|
|
175
|
+
const data = opts.data;
|
|
176
|
+
const lessonId = opts.lessonId;
|
|
177
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
178
|
+
return { name: "quiz_completed", ...base, lessonId, data };
|
|
179
|
+
}
|
|
180
|
+
case "interaction":
|
|
181
|
+
return {
|
|
182
|
+
name: "interaction",
|
|
183
|
+
...base,
|
|
184
|
+
lessonId: opts.lessonId,
|
|
185
|
+
data: opts.data
|
|
186
|
+
};
|
|
187
|
+
default:
|
|
188
|
+
return { name: opts.name, ...base };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function tryBuildTrackEvent(opts) {
|
|
192
|
+
const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
|
|
193
|
+
if (isQuiz && !opts.lessonId) {
|
|
194
|
+
if (isDevEnvironment() && !warnedMissingQuizLesson) {
|
|
195
|
+
warnedMissingQuizLesson = true;
|
|
196
|
+
console.warn(
|
|
197
|
+
`[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return buildTrackEvent(opts);
|
|
203
|
+
}
|
|
59
204
|
|
|
60
205
|
// src/runtime/ports.ts
|
|
61
206
|
function createNoopStorage() {
|
|
@@ -84,24 +229,62 @@ function createSessionStoragePort() {
|
|
|
84
229
|
};
|
|
85
230
|
}
|
|
86
231
|
|
|
232
|
+
// src/runtime/progress.ts
|
|
233
|
+
function createProgressController() {
|
|
234
|
+
let activeLessonId;
|
|
235
|
+
let completedLessonIds = /* @__PURE__ */ new Set();
|
|
236
|
+
let courseCompleted = false;
|
|
237
|
+
const lessonStartTimes = /* @__PURE__ */ new Map();
|
|
238
|
+
return {
|
|
239
|
+
getState: () => ({
|
|
240
|
+
activeLessonId,
|
|
241
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
242
|
+
courseCompleted
|
|
243
|
+
}),
|
|
244
|
+
setActiveLesson: (lessonId, startedAtMs) => {
|
|
245
|
+
const previousLessonId = activeLessonId;
|
|
246
|
+
activeLessonId = lessonId;
|
|
247
|
+
lessonStartTimes.set(lessonId, startedAtMs);
|
|
248
|
+
return { previousLessonId };
|
|
249
|
+
},
|
|
250
|
+
completeLesson: (lessonId, completedAtMs) => {
|
|
251
|
+
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
252
|
+
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
253
|
+
const startedAt = lessonStartTimes.get(lessonId);
|
|
254
|
+
lessonStartTimes.delete(lessonId);
|
|
255
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
256
|
+
return { durationMs, didComplete: true };
|
|
257
|
+
},
|
|
258
|
+
completeCourse: () => {
|
|
259
|
+
if (courseCompleted) return { didComplete: false };
|
|
260
|
+
courseCompleted = true;
|
|
261
|
+
return { didComplete: true };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
87
266
|
// src/runtime/xapi.ts
|
|
88
|
-
var
|
|
267
|
+
var import_xapi2 = require("@lessonkit/xapi");
|
|
89
268
|
function createXapiClientFromConfig(config, queue) {
|
|
90
269
|
if (config.xapi?.enabled === false) return null;
|
|
91
270
|
if (config.xapi?.client) return config.xapi.client;
|
|
92
|
-
|
|
93
|
-
return (0,
|
|
271
|
+
if (!config.courseId) return null;
|
|
272
|
+
return (0, import_xapi2.createXAPIClient)({
|
|
273
|
+
courseId: config.courseId,
|
|
274
|
+
transport: config.xapi?.transport,
|
|
275
|
+
queue
|
|
276
|
+
});
|
|
94
277
|
}
|
|
95
278
|
|
|
96
279
|
// src/runtime/session.ts
|
|
97
|
-
var
|
|
280
|
+
var import_core2 = require("@lessonkit/core");
|
|
98
281
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
99
282
|
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
100
283
|
function resolveSessionId(storage, provided) {
|
|
101
284
|
if (provided) return provided;
|
|
102
285
|
const existing = storage.getItem(SESSION_STORAGE_KEY);
|
|
103
286
|
if (existing) return existing;
|
|
104
|
-
const id = (0,
|
|
287
|
+
const id = (0, import_core2.createSessionId)();
|
|
105
288
|
storage.setItem(SESSION_STORAGE_KEY, id);
|
|
106
289
|
return id;
|
|
107
290
|
}
|
|
@@ -128,16 +311,16 @@ function disposeTrackingClient(client) {
|
|
|
128
311
|
var defaultStorage = createSessionStoragePort();
|
|
129
312
|
function createTrackingClientFromConfig(config) {
|
|
130
313
|
if (config.tracking?.enabled === false) {
|
|
131
|
-
return (0,
|
|
314
|
+
return (0, import_core3.createTrackingClient)();
|
|
132
315
|
}
|
|
133
|
-
return (0,
|
|
316
|
+
return (0, import_core3.createTrackingClient)({
|
|
134
317
|
sink: config.tracking?.sink,
|
|
135
318
|
batchSink: config.tracking?.batchSink,
|
|
136
319
|
batch: config.tracking?.batch
|
|
137
320
|
});
|
|
138
321
|
}
|
|
139
322
|
function LessonkitProvider(props) {
|
|
140
|
-
const config = props.config
|
|
323
|
+
const config = props.config;
|
|
141
324
|
const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
|
|
142
325
|
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
143
326
|
const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
|
|
@@ -146,50 +329,16 @@ function LessonkitProvider(props) {
|
|
|
146
329
|
userRef.current = config.session?.user;
|
|
147
330
|
const courseIdRef = (0, import_react.useRef)(config.courseId);
|
|
148
331
|
courseIdRef.current = config.courseId;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const prev = trackingRef.current;
|
|
160
|
-
const next = createTrackingClientFromConfig(config);
|
|
161
|
-
trackingRef.current = next;
|
|
162
|
-
setTracking(next);
|
|
163
|
-
const sessionId = sessionIdRef.current;
|
|
164
|
-
const cid = courseIdRef.current;
|
|
165
|
-
const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
|
|
166
|
-
if (shouldEmitCourseStarted) {
|
|
167
|
-
if (cid) {
|
|
168
|
-
markCourseStarted(defaultStorage, sessionId, cid);
|
|
169
|
-
} else {
|
|
170
|
-
courseStartedInProviderRef.current = true;
|
|
171
|
-
}
|
|
172
|
-
next.track({
|
|
173
|
-
name: "course_started",
|
|
174
|
-
timestamp: (0, import_core2.nowIso)(),
|
|
175
|
-
courseId: cid,
|
|
176
|
-
sessionId,
|
|
177
|
-
attemptId: attemptIdRef.current,
|
|
178
|
-
user: userRef.current
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
return () => {
|
|
182
|
-
disposeTrackingClient(prev);
|
|
183
|
-
};
|
|
184
|
-
}, [
|
|
185
|
-
trackingEnabled,
|
|
186
|
-
trackingSink,
|
|
187
|
-
trackingBatchSink,
|
|
188
|
-
batchEnabled,
|
|
189
|
-
batchFlushIntervalMs,
|
|
190
|
-
batchMaxBatchSize
|
|
191
|
-
]);
|
|
192
|
-
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi2.createInMemoryXAPIQueue)());
|
|
332
|
+
const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
|
|
333
|
+
lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
|
|
334
|
+
const progressRef = (0, import_react.useRef)(createProgressController());
|
|
335
|
+
const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
|
|
336
|
+
const syncProgress = (0, import_react.useCallback)(() => {
|
|
337
|
+
setProgress(progressRef.current.getState());
|
|
338
|
+
}, []);
|
|
339
|
+
const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
|
|
340
|
+
activeLessonIdRef.current = progress.activeLessonId;
|
|
341
|
+
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
|
|
193
342
|
const xapiRef = (0, import_react.useRef)(null);
|
|
194
343
|
const [xapi, setXapi] = (0, import_react.useState)(null);
|
|
195
344
|
const xapiEnabled = config.xapi?.enabled;
|
|
@@ -201,6 +350,25 @@ function LessonkitProvider(props) {
|
|
|
201
350
|
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
202
351
|
xapiRef.current = next;
|
|
203
352
|
setXapi(next);
|
|
353
|
+
if (next && !prev) {
|
|
354
|
+
const sessionId = sessionIdRef.current;
|
|
355
|
+
const cid = courseIdRef.current;
|
|
356
|
+
if (hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
357
|
+
try {
|
|
358
|
+
const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
|
|
359
|
+
buildTrackEvent({
|
|
360
|
+
name: "course_started",
|
|
361
|
+
courseId: cid,
|
|
362
|
+
sessionId,
|
|
363
|
+
attemptId: attemptIdRef.current,
|
|
364
|
+
user: userRef.current
|
|
365
|
+
})
|
|
366
|
+
);
|
|
367
|
+
if (statement) next.send(statement);
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
204
372
|
void (async () => {
|
|
205
373
|
if (prev) {
|
|
206
374
|
try {
|
|
@@ -217,21 +385,59 @@ function LessonkitProvider(props) {
|
|
|
217
385
|
void prev?.flush();
|
|
218
386
|
};
|
|
219
387
|
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
388
|
+
const trackingRef = (0, import_react.useRef)((0, import_core3.createTrackingClient)());
|
|
389
|
+
const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
|
|
390
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
391
|
+
const trackingSink = config.tracking?.sink;
|
|
392
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
393
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
394
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
395
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
396
|
+
useIsoLayoutEffect(() => {
|
|
397
|
+
const prev = trackingRef.current;
|
|
398
|
+
const next = createTrackingClientFromConfig(config);
|
|
399
|
+
trackingRef.current = next;
|
|
400
|
+
setTracking(next);
|
|
401
|
+
const sessionId = sessionIdRef.current;
|
|
402
|
+
const cid = courseIdRef.current;
|
|
403
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
404
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
405
|
+
emitTelemetry(
|
|
406
|
+
next,
|
|
407
|
+
xapiRef.current,
|
|
408
|
+
buildTrackEvent({
|
|
409
|
+
name: "course_started",
|
|
410
|
+
courseId: cid,
|
|
411
|
+
sessionId,
|
|
412
|
+
attemptId: attemptIdRef.current,
|
|
413
|
+
user: userRef.current
|
|
414
|
+
}),
|
|
415
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return () => {
|
|
419
|
+
disposeTrackingClient(prev);
|
|
420
|
+
};
|
|
421
|
+
}, [
|
|
422
|
+
trackingEnabled,
|
|
423
|
+
trackingSink,
|
|
424
|
+
trackingBatchSink,
|
|
425
|
+
batchEnabled,
|
|
426
|
+
batchFlushIntervalMs,
|
|
427
|
+
batchMaxBatchSize
|
|
428
|
+
]);
|
|
429
|
+
const emitWithBridge = (0, import_react.useCallback)(
|
|
430
|
+
(trackingClient, event) => {
|
|
431
|
+
emitTelemetry(trackingClient, xapiRef.current, event, {
|
|
432
|
+
lxpackBridge: lxpackBridgeModeRef.current
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
[]
|
|
436
|
+
);
|
|
230
437
|
const track = (0, import_react.useCallback)(
|
|
231
438
|
(name, data, opts) => {
|
|
232
|
-
|
|
439
|
+
const event = tryBuildTrackEvent({
|
|
233
440
|
name,
|
|
234
|
-
timestamp: (0, import_core2.nowIso)(),
|
|
235
441
|
courseId: courseIdRef.current,
|
|
236
442
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
237
443
|
sessionId: sessionIdRef.current,
|
|
@@ -239,54 +445,85 @@ function LessonkitProvider(props) {
|
|
|
239
445
|
user: userRef.current,
|
|
240
446
|
data
|
|
241
447
|
});
|
|
448
|
+
if (!event) return;
|
|
449
|
+
emitWithBridge(trackingRef.current, event);
|
|
242
450
|
},
|
|
243
|
-
[]
|
|
451
|
+
[emitWithBridge]
|
|
244
452
|
);
|
|
453
|
+
const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
|
|
454
|
+
(0, import_react.useEffect)(() => {
|
|
455
|
+
if (prevCourseIdRef.current === config.courseId) return;
|
|
456
|
+
prevCourseIdRef.current = config.courseId;
|
|
457
|
+
progressRef.current = createProgressController();
|
|
458
|
+
syncProgress();
|
|
459
|
+
const sessionId = sessionIdRef.current;
|
|
460
|
+
const cid = config.courseId;
|
|
461
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
462
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
463
|
+
emitTelemetry(
|
|
464
|
+
trackingRef.current,
|
|
465
|
+
xapiRef.current,
|
|
466
|
+
buildTrackEvent({
|
|
467
|
+
name: "course_started",
|
|
468
|
+
courseId: cid,
|
|
469
|
+
sessionId,
|
|
470
|
+
attemptId: attemptIdRef.current,
|
|
471
|
+
user: userRef.current
|
|
472
|
+
}),
|
|
473
|
+
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}, [config.courseId, syncProgress]);
|
|
245
477
|
(0, import_react.useEffect)(() => {
|
|
246
478
|
return () => {
|
|
247
479
|
trackingRef.current?.flush?.();
|
|
248
480
|
void xapiRef.current?.flush();
|
|
249
481
|
};
|
|
250
482
|
}, []);
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
activeLessonIdRef.current = lessonId;
|
|
254
|
-
setActiveLessonId(lessonId);
|
|
255
|
-
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
256
|
-
track("lesson_started", { lessonId }, { lessonId });
|
|
257
|
-
xapiRef.current?.startedLesson({ lessonId });
|
|
258
|
-
}, [track]);
|
|
259
|
-
const completeLesson = (0, import_react.useCallback)(
|
|
260
|
-
(lessonId) => {
|
|
261
|
-
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
262
|
-
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
263
|
-
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
264
|
-
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
265
|
-
lessonStartTimesRef.current.delete(lessonId);
|
|
266
|
-
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
483
|
+
const emitLessonCompleted = (0, import_react.useCallback)(
|
|
484
|
+
(lessonId, durationMs) => {
|
|
267
485
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
268
486
|
if (durationMs !== void 0) {
|
|
269
487
|
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
270
488
|
}
|
|
271
|
-
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
272
489
|
},
|
|
273
490
|
[track]
|
|
274
491
|
);
|
|
492
|
+
const completeLesson = (0, import_react.useCallback)(
|
|
493
|
+
(lessonId) => {
|
|
494
|
+
const result = progressRef.current.completeLesson(lessonId, Date.now());
|
|
495
|
+
if (!result.didComplete) return;
|
|
496
|
+
syncProgress();
|
|
497
|
+
emitLessonCompleted(lessonId, result.durationMs);
|
|
498
|
+
},
|
|
499
|
+
[syncProgress, emitLessonCompleted]
|
|
500
|
+
);
|
|
501
|
+
const setActiveLesson = (0, import_react.useCallback)(
|
|
502
|
+
(lessonId) => {
|
|
503
|
+
const current = progressRef.current.getState();
|
|
504
|
+
if (current.activeLessonId === lessonId) return;
|
|
505
|
+
const previous = current.activeLessonId;
|
|
506
|
+
if (previous && previous !== lessonId) {
|
|
507
|
+
const completed = progressRef.current.completeLesson(previous, Date.now());
|
|
508
|
+
if (completed.didComplete) {
|
|
509
|
+
emitLessonCompleted(previous, completed.durationMs);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
513
|
+
syncProgress();
|
|
514
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
515
|
+
},
|
|
516
|
+
[track, syncProgress, emitLessonCompleted]
|
|
517
|
+
);
|
|
275
518
|
const completeCourse = (0, import_react.useCallback)(() => {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
519
|
+
const result = progressRef.current.completeCourse();
|
|
520
|
+
if (!result.didComplete) return;
|
|
521
|
+
syncProgress();
|
|
279
522
|
track("course_completed");
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
activeLessonId,
|
|
285
|
-
completedLessonIds: new Set(completedLessonIds),
|
|
286
|
-
courseCompleted
|
|
287
|
-
}),
|
|
288
|
-
[activeLessonId, completedLessonIds, courseCompleted]
|
|
289
|
-
);
|
|
523
|
+
}, [track, syncProgress]);
|
|
524
|
+
const sessionUser = config.session?.user;
|
|
525
|
+
const sessionAttemptId = config.session?.attemptId;
|
|
526
|
+
const sessionConfiguredId = config.session?.sessionId;
|
|
290
527
|
const runtime = (0, import_react.useMemo)(
|
|
291
528
|
() => ({
|
|
292
529
|
config,
|
|
@@ -299,7 +536,19 @@ function LessonkitProvider(props) {
|
|
|
299
536
|
completeCourse,
|
|
300
537
|
track
|
|
301
538
|
}),
|
|
302
|
-
[
|
|
539
|
+
[
|
|
540
|
+
config,
|
|
541
|
+
tracking,
|
|
542
|
+
xapi,
|
|
543
|
+
progress,
|
|
544
|
+
setActiveLesson,
|
|
545
|
+
completeLesson,
|
|
546
|
+
completeCourse,
|
|
547
|
+
track,
|
|
548
|
+
sessionUser,
|
|
549
|
+
sessionAttemptId,
|
|
550
|
+
sessionConfiguredId
|
|
551
|
+
]
|
|
303
552
|
);
|
|
304
553
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
305
554
|
}
|
|
@@ -338,9 +587,28 @@ function useQuizState() {
|
|
|
338
587
|
);
|
|
339
588
|
}
|
|
340
589
|
|
|
590
|
+
// src/runtime/validateComponentId.ts
|
|
591
|
+
var import_core4 = require("@lessonkit/core");
|
|
592
|
+
var warnedPaths = /* @__PURE__ */ new Set();
|
|
593
|
+
function isDevEnvironment2() {
|
|
594
|
+
const g = globalThis;
|
|
595
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
596
|
+
}
|
|
597
|
+
function warnInvalidComponentId(id, path) {
|
|
598
|
+
if (!isDevEnvironment2()) return;
|
|
599
|
+
const key = `${path}:${String(id)}`;
|
|
600
|
+
if (warnedPaths.has(key)) return;
|
|
601
|
+
const result = (0, import_core4.validateId)(id, path);
|
|
602
|
+
if (result.ok) return;
|
|
603
|
+
warnedPaths.add(key);
|
|
604
|
+
const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
605
|
+
console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
341
608
|
// src/components.tsx
|
|
342
609
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
343
610
|
function Course(props) {
|
|
611
|
+
warnInvalidComponentId(props.courseId, "courseId");
|
|
344
612
|
const providerConfig = (0, import_react3.useMemo)(
|
|
345
613
|
() => ({ ...props.config, courseId: props.courseId }),
|
|
346
614
|
[props.config, props.courseId]
|
|
@@ -351,15 +619,23 @@ function Course(props) {
|
|
|
351
619
|
] }) });
|
|
352
620
|
}
|
|
353
621
|
function Lesson(props) {
|
|
622
|
+
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
354
623
|
const { setActiveLesson } = useLessonkit();
|
|
355
624
|
const { completeLesson } = useCompletion();
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const id = props.lessonId ?? generatedId;
|
|
625
|
+
const id = props.lessonId;
|
|
626
|
+
const pendingCompleteRef = (0, import_react3.useRef)(null);
|
|
359
627
|
(0, import_react3.useEffect)(() => {
|
|
628
|
+
if (pendingCompleteRef.current !== null) {
|
|
629
|
+
clearTimeout(pendingCompleteRef.current);
|
|
630
|
+
pendingCompleteRef.current = null;
|
|
631
|
+
}
|
|
360
632
|
setActiveLesson(id);
|
|
361
633
|
return () => {
|
|
362
|
-
|
|
634
|
+
const lessonId = id;
|
|
635
|
+
pendingCompleteRef.current = setTimeout(() => {
|
|
636
|
+
pendingCompleteRef.current = null;
|
|
637
|
+
completeLesson(lessonId);
|
|
638
|
+
}, 0);
|
|
363
639
|
};
|
|
364
640
|
}, [id, setActiveLesson, completeLesson]);
|
|
365
641
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
|
|
@@ -368,11 +644,13 @@ function Lesson(props) {
|
|
|
368
644
|
] });
|
|
369
645
|
}
|
|
370
646
|
function Scenario(props) {
|
|
371
|
-
|
|
647
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
648
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
|
|
372
649
|
}
|
|
373
650
|
function Reflection(props) {
|
|
651
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
374
652
|
const promptId = (0, import_react3.useId)();
|
|
375
|
-
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
|
|
653
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
|
|
376
654
|
props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
|
|
377
655
|
props.children,
|
|
378
656
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
@@ -385,14 +663,23 @@ function Reflection(props) {
|
|
|
385
663
|
] });
|
|
386
664
|
}
|
|
387
665
|
function KnowledgeCheck(props) {
|
|
388
|
-
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
666
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
667
|
+
Quiz,
|
|
668
|
+
{
|
|
669
|
+
checkId: props.checkId,
|
|
670
|
+
question: props.question,
|
|
671
|
+
choices: props.choices,
|
|
672
|
+
answer: props.answer
|
|
673
|
+
}
|
|
674
|
+
);
|
|
389
675
|
}
|
|
390
676
|
function Quiz(props) {
|
|
677
|
+
warnInvalidComponentId(props.checkId, "checkId");
|
|
391
678
|
const quiz = useQuizState();
|
|
392
679
|
const [selected, setSelected] = (0, import_react3.useState)(null);
|
|
393
680
|
const completedRef = (0, import_react3.useRef)(false);
|
|
394
681
|
const questionId = (0, import_react3.useId)();
|
|
395
|
-
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
|
|
682
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
|
|
396
683
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
|
|
397
684
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
|
|
398
685
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
|
|
@@ -407,10 +694,15 @@ function Quiz(props) {
|
|
|
407
694
|
onChange: () => {
|
|
408
695
|
setSelected(c);
|
|
409
696
|
const correct = c === props.answer;
|
|
410
|
-
quiz.answer({
|
|
697
|
+
quiz.answer({
|
|
698
|
+
checkId: props.checkId,
|
|
699
|
+
question: props.question,
|
|
700
|
+
choice: c,
|
|
701
|
+
correct
|
|
702
|
+
});
|
|
411
703
|
if (correct && !completedRef.current) {
|
|
412
704
|
completedRef.current = true;
|
|
413
|
-
quiz.complete({ score: 1, maxScore: 1 });
|
|
705
|
+
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1, passingScore: 1 });
|
|
414
706
|
}
|
|
415
707
|
}
|
|
416
708
|
}
|
|
@@ -429,10 +721,6 @@ function ProgressTracker() {
|
|
|
429
721
|
completed
|
|
430
722
|
] }) });
|
|
431
723
|
}
|
|
432
|
-
function sanitizeLessonId(id) {
|
|
433
|
-
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
434
|
-
return s.length ? s : "id";
|
|
435
|
-
}
|
|
436
724
|
|
|
437
725
|
// src/theme/ThemeProvider.tsx
|
|
438
726
|
var import_react4 = __toESM(require("react"), 1);
|