@burdenoff/microfe-technospam 2026.1101.4 → 2026.1102.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -3
- package/dist/technospam/TechnoSpamRoutes.d.ts.map +1 -1
- package/dist/technospam/TechnoSpamRoutes.js +91 -90
- package/dist/technospam/TechnoSpamRoutes.js.map +1 -1
- package/dist/technospam/api/technospamApi.d.ts +21 -7
- package/dist/technospam/api/technospamApi.d.ts.map +1 -1
- package/dist/technospam/api/technospamApi.js +317 -284
- package/dist/technospam/api/technospamApi.js.map +1 -1
- package/dist/technospam/components/CertificateVerifyLink.d.ts +11 -0
- package/dist/technospam/components/CertificateVerifyLink.d.ts.map +1 -0
- package/dist/technospam/components/CertificateVerifyLink.js +63 -0
- package/dist/technospam/components/CertificateVerifyLink.js.map +1 -0
- package/dist/technospam/components/index.d.ts +1 -0
- package/dist/technospam/components/index.d.ts.map +1 -1
- package/dist/technospam/components/index.js +1 -0
- package/dist/technospam/components/lesson/OfflineProgressSync.d.ts +9 -0
- package/dist/technospam/components/lesson/OfflineProgressSync.d.ts.map +1 -0
- package/dist/technospam/components/lesson/OfflineProgressSync.js +20 -0
- package/dist/technospam/components/lesson/OfflineProgressSync.js.map +1 -0
- package/dist/technospam/components/lesson/SaveCourseOffline.d.ts +17 -0
- package/dist/technospam/components/lesson/SaveCourseOffline.d.ts.map +1 -0
- package/dist/technospam/components/lesson/SaveCourseOffline.js +73 -0
- package/dist/technospam/components/lesson/SaveCourseOffline.js.map +1 -0
- package/dist/technospam/components/lesson/SavedCourseOutline.d.ts +17 -0
- package/dist/technospam/components/lesson/SavedCourseOutline.d.ts.map +1 -0
- package/dist/technospam/components/lesson/SavedCourseOutline.js +77 -0
- package/dist/technospam/components/lesson/SavedCourseOutline.js.map +1 -0
- package/dist/technospam/hooks/useOfflineLessons.d.ts +26 -0
- package/dist/technospam/hooks/useOfflineLessons.d.ts.map +1 -0
- package/dist/technospam/hooks/useOfflineLessons.js +70 -0
- package/dist/technospam/hooks/useOfflineLessons.js.map +1 -0
- package/dist/technospam/index.d.ts +2 -0
- package/dist/technospam/index.d.ts.map +1 -1
- package/dist/technospam/index.js +2 -0
- package/dist/technospam/lib/certificateVerify.d.ts +71 -0
- package/dist/technospam/lib/certificateVerify.d.ts.map +1 -0
- package/dist/technospam/lib/certificateVerify.js +55 -0
- package/dist/technospam/lib/certificateVerify.js.map +1 -0
- package/dist/technospam/lib/offlineLessons.d.ts +97 -0
- package/dist/technospam/lib/offlineLessons.d.ts.map +1 -0
- package/dist/technospam/lib/offlineLessons.js +168 -0
- package/dist/technospam/lib/offlineLessons.js.map +1 -0
- package/dist/technospam/pages/CoursesCourseDetailsPagePage.d.ts.map +1 -1
- package/dist/technospam/pages/CoursesCourseDetailsPagePage.js +241 -198
- package/dist/technospam/pages/CoursesCourseDetailsPagePage.js.map +1 -1
- package/dist/technospam/pages/CoursesLessonPlayerPagePage.d.ts.map +1 -1
- package/dist/technospam/pages/CoursesLessonPlayerPagePage.js +216 -138
- package/dist/technospam/pages/CoursesLessonPlayerPagePage.js.map +1 -1
- package/dist/technospam/pages/CoursesMyLearningPagePage.d.ts.map +1 -1
- package/dist/technospam/pages/CoursesMyLearningPagePage.js +104 -96
- package/dist/technospam/pages/CoursesMyLearningPagePage.js.map +1 -1
- package/dist/technospam/pages/public/CertificateVerifyPage.d.ts +4 -0
- package/dist/technospam/pages/public/CertificateVerifyPage.d.ts.map +1 -0
- package/dist/technospam/pages/public/CertificateVerifyPage.js +257 -0
- package/dist/technospam/pages/public/CertificateVerifyPage.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { notify as e } from "../../lib/toast.js";
|
|
2
|
+
import { fetchLessonForOffline as t } from "../../api/technospamApi.js";
|
|
3
|
+
import { removeSavedCourse as n, saveLessonSnapshot as r, savedLessonIds as i } from "../../lib/offlineLessons.js";
|
|
4
|
+
import { useOnlineStatus as a } from "../../hooks/useOfflineLessons.js";
|
|
5
|
+
import { useMemo as o, useState as s } from "react";
|
|
6
|
+
import { CloudDownload as c, CloudOff as l, Trash2 as u } from "lucide-react";
|
|
7
|
+
import { jsx as d, jsxs as f } from "react/jsx-runtime";
|
|
8
|
+
import { Button as p } from "@burdenoff/fe-libs/ui";
|
|
9
|
+
import { useApolloClient as m } from "@apollo/client/react";
|
|
10
|
+
//#region src/technospam/components/lesson/SaveCourseOffline.tsx
|
|
11
|
+
function h({ scope: h, courseId: g, lessons: _, enrollment: v }) {
|
|
12
|
+
let y = m(), b = a(), x = _.filter((e) => !e.isLocked), [S, C] = s(0), w = o(() => i(h, g), [
|
|
13
|
+
h,
|
|
14
|
+
g,
|
|
15
|
+
S
|
|
16
|
+
]), [T, E] = s(null), D = x.filter((e) => w.includes(e.id)).length, O = x.length > 0 && D === x.length;
|
|
17
|
+
if (x.length === 0 || !h) return null;
|
|
18
|
+
let k = async () => {
|
|
19
|
+
E(0);
|
|
20
|
+
for (let e = 0; e < x.length; e++) {
|
|
21
|
+
let n = x[e], i = await t(y, g, n.id);
|
|
22
|
+
i && r(h, i, v), E(e + 1);
|
|
23
|
+
}
|
|
24
|
+
E(null), C((e) => e + 1);
|
|
25
|
+
let n = i(h, g), a = x.filter((e) => n.includes(e.id)).length;
|
|
26
|
+
x.length > 150 ? e.warning("Course too large to save in full", `This device keeps up to 150 lessons offline. ${a} of ${x.length} lessons of this course are available offline.`) : a < x.length ? e.warning("Some lessons were not saved", `${a} of ${x.length} lessons are available offline. Try again to save the rest.`) : e.success("Saved for offline", "You can read this course without a connection.");
|
|
27
|
+
};
|
|
28
|
+
return T === null ? O ? /* @__PURE__ */ f("div", {
|
|
29
|
+
className: "flex items-center gap-1.5",
|
|
30
|
+
"data-testid": "course-saved-offline",
|
|
31
|
+
children: [/* @__PURE__ */ f("span", {
|
|
32
|
+
className: "inline-flex items-center gap-1 rounded-full bg-accent-primary/6 px-2.5 py-1 text-xs font-medium text-accent-primary",
|
|
33
|
+
children: [/* @__PURE__ */ d(l, { size: 12 }), " Available offline"]
|
|
34
|
+
}), /* @__PURE__ */ d(p, {
|
|
35
|
+
size: "sm",
|
|
36
|
+
variant: "ghost",
|
|
37
|
+
onClick: () => {
|
|
38
|
+
n(h, g), C((e) => e + 1), e.info("Removed from this device");
|
|
39
|
+
},
|
|
40
|
+
"aria-label": "Remove offline copy",
|
|
41
|
+
children: /* @__PURE__ */ d(u, { size: 14 })
|
|
42
|
+
})]
|
|
43
|
+
}) : /* @__PURE__ */ f(p, {
|
|
44
|
+
size: "sm",
|
|
45
|
+
variant: "outline",
|
|
46
|
+
onClick: () => void k(),
|
|
47
|
+
disabled: !b,
|
|
48
|
+
title: b ? void 0 : "Connect to the internet to save this course",
|
|
49
|
+
"data-testid": "save-course-offline",
|
|
50
|
+
children: [
|
|
51
|
+
/* @__PURE__ */ d(c, { size: 14 }),
|
|
52
|
+
" Save for offline",
|
|
53
|
+
D > 0 ? ` (${D}/${x.length})` : ""
|
|
54
|
+
]
|
|
55
|
+
}) : /* @__PURE__ */ f(p, {
|
|
56
|
+
size: "sm",
|
|
57
|
+
variant: "outline",
|
|
58
|
+
disabled: !0,
|
|
59
|
+
"data-testid": "save-course-offline",
|
|
60
|
+
children: [
|
|
61
|
+
/* @__PURE__ */ d(c, { size: 14 }),
|
|
62
|
+
" Saving ",
|
|
63
|
+
T,
|
|
64
|
+
"/",
|
|
65
|
+
x.length,
|
|
66
|
+
"…"
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { h as SaveCourseOffline };
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=SaveCourseOffline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SaveCourseOffline.js","names":[],"sources":["../../../../src/technospam/components/lesson/SaveCourseOffline.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport { useApolloClient } from '@apollo/client/react';\nimport { CloudDownload, CloudOff, Trash2 } from 'lucide-react';\nimport { Button } from '@burdenoff/fe-libs/ui';\n\nimport { fetchLessonForOffline, type TecLesson } from '../../api/technospamApi';\nimport { useOnlineStatus } from '../../hooks/useOfflineLessons';\nimport { notify } from '../../lib/toast';\nimport {\n MAX_SAVED_LESSONS,\n removeSavedCourse,\n saveLessonSnapshot,\n savedLessonIds,\n type SnapshotEnrollment,\n} from '../../lib/offlineLessons';\n\nexport interface SaveCourseOfflineProps {\n /** Storage scope for this learner in this workspace (`offlineScope`). */\n scope: string;\n courseId: string;\n lessons: Pick<TecLesson, 'id' | 'isLocked'>[];\n enrollment: SnapshotEnrollment | null;\n}\n\n/**\n * \"Save for offline\" for one course (BOFF-4418): downloads every unlocked\n * lesson of the course to the device so it can be read on the go without a\n * connection. Locked lessons are skipped (there is nothing to read in them).\n */\nexport function SaveCourseOffline({\n scope,\n courseId,\n lessons,\n enrollment,\n}: SaveCourseOfflineProps) {\n const client = useApolloClient();\n const online = useOnlineStatus();\n const readable = lessons.filter((lesson) => !lesson.isLocked);\n // Bumped after this component saves or removes lessons; storage is the\n // source of truth, re-read whenever the scope, course or version changes.\n const [version, setVersion] = useState(0);\n const saved = useMemo(() => {\n void version;\n return savedLessonIds(scope, courseId);\n }, [scope, courseId, version]);\n const [progress, setProgress] = useState<number | null>(null);\n\n const savedCount = readable.filter((lesson) => saved.includes(lesson.id)).length;\n const allSaved = readable.length > 0 && savedCount === readable.length;\n\n if (readable.length === 0 || !scope) return null;\n\n const handleSave = async (): Promise<void> => {\n setProgress(0);\n for (let i = 0; i < readable.length; i++) {\n const lesson = readable[i] as Pick<TecLesson, 'id' | 'isLocked'>;\n const data = await fetchLessonForOffline(client, courseId, lesson.id);\n if (data) saveLessonSnapshot(scope, data, enrollment);\n setProgress(i + 1);\n }\n setProgress(null);\n setVersion((v) => v + 1);\n // Count what storage actually kept, not what each write reported: the\n // device keeps at most MAX_SAVED_LESSONS lessons, so saving a larger course\n // evicts its own earlier lessons even though every write succeeded.\n const retained = savedLessonIds(scope, courseId);\n const keptCount = readable.filter((lesson) => retained.includes(lesson.id)).length;\n if (readable.length > MAX_SAVED_LESSONS) {\n notify.warning(\n 'Course too large to save in full',\n `This device keeps up to ${MAX_SAVED_LESSONS} lessons offline. ${keptCount} of ${readable.length} lessons of this course are available offline.`\n );\n } else if (keptCount < readable.length) {\n notify.warning(\n 'Some lessons were not saved',\n `${keptCount} of ${readable.length} lessons are available offline. Try again to save the rest.`\n );\n } else {\n notify.success('Saved for offline', 'You can read this course without a connection.');\n }\n };\n\n const handleRemove = (): void => {\n removeSavedCourse(scope, courseId);\n setVersion((v) => v + 1);\n notify.info('Removed from this device');\n };\n\n if (progress !== null) {\n return (\n <Button size=\"sm\" variant=\"outline\" disabled data-testid=\"save-course-offline\">\n <CloudDownload size={14} /> Saving {progress}/{readable.length}…\n </Button>\n );\n }\n\n if (allSaved) {\n return (\n <div className=\"flex items-center gap-1.5\" data-testid=\"course-saved-offline\">\n <span className=\"inline-flex items-center gap-1 rounded-full bg-accent-primary/6 px-2.5 py-1 text-xs font-medium text-accent-primary\">\n <CloudOff size={12} /> Available offline\n </span>\n <Button size=\"sm\" variant=\"ghost\" onClick={handleRemove} aria-label=\"Remove offline copy\">\n <Trash2 size={14} />\n </Button>\n </div>\n );\n }\n\n return (\n <Button\n size=\"sm\"\n variant=\"outline\"\n onClick={() => void handleSave()}\n disabled={!online}\n title={online ? undefined : 'Connect to the internet to save this course'}\n data-testid=\"save-course-offline\"\n >\n <CloudDownload size={14} /> Save for offline\n {savedCount > 0 ? ` (${savedCount}/${readable.length})` : ''}\n </Button>\n );\n}\n"],"mappings":";;;;;;;;;;AA6BA,SAAgB,EAAkB,EAChC,UACA,aACA,YACA,iBACyB;CACzB,IAAM,IAAS,GAAiB,EAC1B,IAAS,GAAiB,EAC1B,IAAW,EAAQ,QAAQ,MAAW,CAAC,EAAO,SAAS,EAGvD,CAAC,GAAS,KAAc,EAAS,EAAE,EACnC,IAAQ,QAEL,EAAe,GAAO,EAAS,EACrC;EAAC;EAAO;EAAU;EAAQ,CAAC,EACxB,CAAC,GAAU,KAAe,EAAwB,KAAK,EAEvD,IAAa,EAAS,QAAQ,MAAW,EAAM,SAAS,EAAO,GAAG,CAAC,CAAC,QACpE,IAAW,EAAS,SAAS,KAAK,MAAe,EAAS;AAEhE,KAAI,EAAS,WAAW,KAAK,CAAC,EAAO,QAAO;CAE5C,IAAM,IAAa,YAA2B;AAC5C,IAAY,EAAE;AACd,OAAK,IAAI,IAAI,GAAG,IAAI,EAAS,QAAQ,KAAK;GACxC,IAAM,IAAS,EAAS,IAClB,IAAO,MAAM,EAAsB,GAAQ,GAAU,EAAO,GAAG;AAErE,GADI,KAAM,EAAmB,GAAO,GAAM,EAAW,EACrD,EAAY,IAAI,EAAE;;AAGpB,EADA,EAAY,KAAK,EACjB,GAAY,MAAM,IAAI,EAAE;EAIxB,IAAM,IAAW,EAAe,GAAO,EAAS,EAC1C,IAAY,EAAS,QAAQ,MAAW,EAAS,SAAS,EAAO,GAAG,CAAC,CAAC;AAC5E,EAAI,EAAS,SAAA,MACX,EAAO,QACL,oCACA,gDAAiE,EAAU,MAAM,EAAS,OAAO,gDAClG,GACQ,IAAY,EAAS,SAC9B,EAAO,QACL,+BACA,GAAG,EAAU,MAAM,EAAS,OAAO,6DACpC,GAED,EAAO,QAAQ,qBAAqB,iDAAiD;;AA+BzF,QArBI,MAAa,OAQb,IAEA,kBAAC,OAAD;EAAK,WAAU;EAA4B,eAAY;YAAvD,CACE,kBAAC,QAAD;GAAM,WAAU;aAAhB,CACE,kBAAC,GAAD,EAAU,MAAM,IAAM,CAAA,EAAA,qBACjB;MACP,kBAAC,GAAD;GAAQ,MAAK;GAAK,SAAQ;GAAQ,eApBP;AAG/B,IAFA,EAAkB,GAAO,EAAS,EAClC,GAAY,MAAM,IAAI,EAAE,EACxB,EAAO,KAAK,2BAA2B;;GAiBsB,cAAW;aAClE,kBAAC,GAAD,EAAQ,MAAM,IAAM,CAAA;GACb,CAAA,CACL;MAKR,kBAAC,GAAD;EACE,MAAK;EACL,SAAQ;EACR,eAAe,KAAK,GAAY;EAChC,UAAU,CAAC;EACX,OAAO,IAAS,KAAA,IAAY;EAC5B,eAAY;YANd;GAQE,kBAAC,GAAD,EAAe,MAAM,IAAM,CAAA;;GAC1B,IAAa,IAAI,KAAK,EAAW,GAAG,EAAS,OAAO,KAAK;GACnD;MA9BP,kBAAC,GAAD;EAAQ,MAAK;EAAK,SAAQ;EAAU,UAAA;EAAS,eAAY;YAAzD;GACE,kBAAC,GAAD,EAAe,MAAM,IAAM,CAAA;;GAAS;GAAS;GAAE,EAAS;GAAO;GACxD"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SavedCourseOutline as SavedCourseOutlineData } from '../../lib/offlineLessons';
|
|
2
|
+
|
|
3
|
+
export interface SavedCourseOutlineProps {
|
|
4
|
+
outline: SavedCourseOutlineData;
|
|
5
|
+
/** True when the browser reports a connection (the server was unreachable anyway). */
|
|
6
|
+
online: boolean;
|
|
7
|
+
onOpenLesson: (lessonId: string) => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The course page's offline fallback (BOFF-4418): when the course cannot be
|
|
11
|
+
* loaded from the server, list the lessons saved on this device so a learner
|
|
12
|
+
* who saved the course can still reach them from the course page after a cold
|
|
13
|
+
* start without a connection. Each row opens the lesson player, which reads
|
|
14
|
+
* the saved copy.
|
|
15
|
+
*/
|
|
16
|
+
export declare function SavedCourseOutline({ outline, online, onOpenLesson }: SavedCourseOutlineProps): import("react").JSX.Element;
|
|
17
|
+
//# sourceMappingURL=SavedCourseOutline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SavedCourseOutline.d.ts","sourceRoot":"","sources":["../../../../src/technospam/components/lesson/SavedCourseOutline.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,IAAI,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAG7F,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,sBAAsB,CAAC;IAChC,sFAAsF;IACtF,MAAM,EAAE,OAAO,CAAC;IAChB,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1C;AAOD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,uBAAuB,+BAyD5F"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cn as e } from "../../utils/cn.js";
|
|
2
|
+
import { CheckCircle2 as t, ChevronRight as n, CloudOff as r, FileText as i } from "lucide-react";
|
|
3
|
+
import { jsx as a, jsxs as o } from "react/jsx-runtime";
|
|
4
|
+
import { GlassCard as s } from "@burdenoff/fe-libs/ui";
|
|
5
|
+
//#region src/technospam/components/lesson/SavedCourseOutline.tsx
|
|
6
|
+
function c(e) {
|
|
7
|
+
let t = new Date(e);
|
|
8
|
+
return Number.isNaN(t.getTime()) ? "" : ` on ${t.toLocaleDateString()}`;
|
|
9
|
+
}
|
|
10
|
+
function l({ outline: l, online: u, onOpenLesson: d }) {
|
|
11
|
+
let { course: f, lessons: p, enrollment: m, savedAt: h } = l;
|
|
12
|
+
return /* @__PURE__ */ o("div", {
|
|
13
|
+
className: "space-y-4",
|
|
14
|
+
"data-testid": "course-saved-outline",
|
|
15
|
+
children: [/* @__PURE__ */ o("div", {
|
|
16
|
+
className: "flex items-center gap-2 rounded-xl border border-status-warning-border bg-status-warning-bg-subtle px-4 py-2.5 text-sm text-status-warning-text",
|
|
17
|
+
role: "status",
|
|
18
|
+
children: [
|
|
19
|
+
/* @__PURE__ */ a(r, {
|
|
20
|
+
size: 15,
|
|
21
|
+
className: "shrink-0"
|
|
22
|
+
}),
|
|
23
|
+
u ? "Couldn't reach TechnoSpam. Showing the lessons saved on this device" : "You are offline. Showing the lessons saved on this device",
|
|
24
|
+
c(h),
|
|
25
|
+
"."
|
|
26
|
+
]
|
|
27
|
+
}), /* @__PURE__ */ o(s, {
|
|
28
|
+
className: "p-5",
|
|
29
|
+
children: [
|
|
30
|
+
/* @__PURE__ */ a("h2", {
|
|
31
|
+
className: "text-lg font-semibold text-text-primary",
|
|
32
|
+
children: f.title
|
|
33
|
+
}),
|
|
34
|
+
f.summary ? /* @__PURE__ */ a("p", {
|
|
35
|
+
className: "mt-1 text-sm text-text-secondary",
|
|
36
|
+
children: f.summary
|
|
37
|
+
}) : null,
|
|
38
|
+
/* @__PURE__ */ o("p", {
|
|
39
|
+
className: "mt-2 text-xs text-text-muted",
|
|
40
|
+
children: [
|
|
41
|
+
p.length,
|
|
42
|
+
" ",
|
|
43
|
+
p.length === 1 ? "lesson" : "lessons",
|
|
44
|
+
" saved for offline reading",
|
|
45
|
+
m ? ` · ${Math.round(m.progressPct)}% complete when saved` : ""
|
|
46
|
+
]
|
|
47
|
+
}),
|
|
48
|
+
/* @__PURE__ */ a("ul", {
|
|
49
|
+
className: "mt-4 divide-y divide-border-seam",
|
|
50
|
+
children: p.map((r) => /* @__PURE__ */ a("li", { children: /* @__PURE__ */ o("button", {
|
|
51
|
+
type: "button",
|
|
52
|
+
onClick: () => d(r.id),
|
|
53
|
+
className: "group flex w-full items-center gap-3 rounded-xl px-2 py-3 text-left transition-colors hover:bg-bg-sunken/60",
|
|
54
|
+
children: [
|
|
55
|
+
/* @__PURE__ */ a("span", {
|
|
56
|
+
className: e("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", r.completedByMe ? "bg-status-success-bg-subtle text-status-success-text" : "bg-accent-indigo/15 text-accent-indigo"),
|
|
57
|
+
children: r.completedByMe ? /* @__PURE__ */ a(t, { size: 17 }) : /* @__PURE__ */ a(i, { size: 17 })
|
|
58
|
+
}),
|
|
59
|
+
/* @__PURE__ */ a("span", {
|
|
60
|
+
className: "min-w-0 flex-1 truncate text-sm font-medium text-text-primary",
|
|
61
|
+
children: r.title
|
|
62
|
+
}),
|
|
63
|
+
/* @__PURE__ */ a(n, {
|
|
64
|
+
size: 16,
|
|
65
|
+
className: "shrink-0 text-text-muted transition-transform group-hover:translate-x-0.5"
|
|
66
|
+
})
|
|
67
|
+
]
|
|
68
|
+
}) }, r.id))
|
|
69
|
+
})
|
|
70
|
+
]
|
|
71
|
+
})]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { l as SavedCourseOutline };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=SavedCourseOutline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SavedCourseOutline.js","names":[],"sources":["../../../../src/technospam/components/lesson/SavedCourseOutline.tsx"],"sourcesContent":["import { CheckCircle2, ChevronRight, CloudOff, FileText } from 'lucide-react';\nimport { GlassCard } from '@burdenoff/fe-libs/ui';\n\nimport type { SavedCourseOutline as SavedCourseOutlineData } from '../../lib/offlineLessons';\nimport { cn } from '../../utils/cn';\n\nexport interface SavedCourseOutlineProps {\n outline: SavedCourseOutlineData;\n /** True when the browser reports a connection (the server was unreachable anyway). */\n online: boolean;\n onOpenLesson: (lessonId: string) => void;\n}\n\nfunction savedOnLabel(savedAt: string): string {\n const saved = new Date(savedAt);\n return Number.isNaN(saved.getTime()) ? '' : ` on ${saved.toLocaleDateString()}`;\n}\n\n/**\n * The course page's offline fallback (BOFF-4418): when the course cannot be\n * loaded from the server, list the lessons saved on this device so a learner\n * who saved the course can still reach them from the course page after a cold\n * start without a connection. Each row opens the lesson player, which reads\n * the saved copy.\n */\nexport function SavedCourseOutline({ outline, online, onOpenLesson }: SavedCourseOutlineProps) {\n const { course, lessons, enrollment, savedAt } = outline;\n return (\n <div className=\"space-y-4\" data-testid=\"course-saved-outline\">\n <div\n className=\"flex items-center gap-2 rounded-xl border border-status-warning-border bg-status-warning-bg-subtle px-4 py-2.5 text-sm text-status-warning-text\"\n role=\"status\"\n >\n <CloudOff size={15} className=\"shrink-0\" />\n {online\n ? \"Couldn't reach TechnoSpam. Showing the lessons saved on this device\"\n : 'You are offline. Showing the lessons saved on this device'}\n {savedOnLabel(savedAt)}.\n </div>\n\n <GlassCard className=\"p-5\">\n <h2 className=\"text-lg font-semibold text-text-primary\">{course.title}</h2>\n {course.summary ? (\n <p className=\"mt-1 text-sm text-text-secondary\">{course.summary}</p>\n ) : null}\n <p className=\"mt-2 text-xs text-text-muted\">\n {lessons.length} {lessons.length === 1 ? 'lesson' : 'lessons'} saved for offline reading\n {enrollment ? ` · ${Math.round(enrollment.progressPct)}% complete when saved` : ''}\n </p>\n\n <ul className=\"mt-4 divide-y divide-border-seam\">\n {lessons.map((lesson) => (\n <li key={lesson.id}>\n <button\n type=\"button\"\n onClick={() => onOpenLesson(lesson.id)}\n className=\"group flex w-full items-center gap-3 rounded-xl px-2 py-3 text-left transition-colors hover:bg-bg-sunken/60\"\n >\n <span\n className={cn(\n 'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',\n lesson.completedByMe\n ? 'bg-status-success-bg-subtle text-status-success-text'\n : 'bg-accent-indigo/15 text-accent-indigo'\n )}\n >\n {lesson.completedByMe ? <CheckCircle2 size={17} /> : <FileText size={17} />}\n </span>\n <span className=\"min-w-0 flex-1 truncate text-sm font-medium text-text-primary\">\n {lesson.title}\n </span>\n <ChevronRight\n size={16}\n className=\"shrink-0 text-text-muted transition-transform group-hover:translate-x-0.5\"\n />\n </button>\n </li>\n ))}\n </ul>\n </GlassCard>\n </div>\n );\n}\n"],"mappings":";;;;;AAaA,SAAS,EAAa,GAAyB;CAC7C,IAAM,IAAQ,IAAI,KAAK,EAAQ;AAC/B,QAAO,OAAO,MAAM,EAAM,SAAS,CAAC,GAAG,KAAK,OAAO,EAAM,oBAAoB;;AAU/E,SAAgB,EAAmB,EAAE,YAAS,WAAQ,mBAAyC;CAC7F,IAAM,EAAE,WAAQ,YAAS,eAAY,eAAY;AACjD,QACE,kBAAC,OAAD;EAAK,WAAU;EAAY,eAAY;YAAvC,CACE,kBAAC,OAAD;GACE,WAAU;GACV,MAAK;aAFP;IAIE,kBAAC,GAAD;KAAU,MAAM;KAAI,WAAU;KAAa,CAAA;IAC1C,IACG,wEACA;IACH,EAAa,EAAQ;IAAC;IACnB;MAEN,kBAAC,GAAD;GAAW,WAAU;aAArB;IACE,kBAAC,MAAD;KAAI,WAAU;eAA2C,EAAO;KAAW,CAAA;IAC1E,EAAO,UACN,kBAAC,KAAD;KAAG,WAAU;eAAoC,EAAO;KAAY,CAAA,GAClE;IACJ,kBAAC,KAAD;KAAG,WAAU;eAAb;MACG,EAAQ;MAAO;MAAE,EAAQ,WAAW,IAAI,WAAW;MAAU;MAC7D,IAAa,MAAM,KAAK,MAAM,EAAW,YAAY,CAAC,yBAAyB;MAC9E;;IAEJ,kBAAC,MAAD;KAAI,WAAU;eACX,EAAQ,KAAK,MACZ,kBAAC,MAAD,EAAA,UACE,kBAAC,UAAD;MACE,MAAK;MACL,eAAe,EAAa,EAAO,GAAG;MACtC,WAAU;gBAHZ;OAKE,kBAAC,QAAD;QACE,WAAW,EACT,gEACA,EAAO,gBACH,yDACA,yCACL;kBAEA,EAAO,gBAAgB,kBAAC,GAAD,EAAc,MAAM,IAAM,CAAA,GAAG,kBAAC,GAAD,EAAU,MAAM,IAAM,CAAA;QACtE,CAAA;OACP,kBAAC,QAAD;QAAM,WAAU;kBACb,EAAO;QACH,CAAA;OACP,kBAAC,GAAD;QACE,MAAM;QACN,WAAU;QACV,CAAA;OACK;SACN,EAxBI,EAAO,GAwBX,CACL;KACC,CAAA;IACK;KACR"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Live connectivity, from `navigator.onLine` and the window's online/offline events. */
|
|
2
|
+
export declare function useOnlineStatus(): boolean;
|
|
3
|
+
export interface OfflineProgress {
|
|
4
|
+
/** Lesson ids completed on this device and not yet sent. */
|
|
5
|
+
pendingLessonIds: string[];
|
|
6
|
+
/** Queue a completion made without a connection. False when the device refused to store it. */
|
|
7
|
+
queueCompletion: (courseId: string, lessonId: string) => boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Delays before re-trying a flush that left completions queued while online. */
|
|
10
|
+
export declare const FLUSH_RETRY_DELAYS_MS: readonly [5000, 15000, 30000, 60000, 120000];
|
|
11
|
+
/**
|
|
12
|
+
* Removes the saved lessons of any other user from this device whenever the
|
|
13
|
+
* signed-in user changes, so a shared phone never shows one learner's saved
|
|
14
|
+
* (possibly premium) lessons to the next.
|
|
15
|
+
*/
|
|
16
|
+
export declare function usePurgeOtherUsersLessons(userId: string | undefined): void;
|
|
17
|
+
/**
|
|
18
|
+
* Sends queued lesson completions whenever the device is (or comes back)
|
|
19
|
+
* online, and exposes what is still pending so the player can show a lesson
|
|
20
|
+
* as complete before the server has heard about it.
|
|
21
|
+
*
|
|
22
|
+
* `scope` is {@link offlineScope}(workspaceId, userId): the outbox belongs to
|
|
23
|
+
* one learner in one workspace and is only ever flushed under that session.
|
|
24
|
+
*/
|
|
25
|
+
export declare function useOfflineProgressSync(scope: string): OfflineProgress;
|
|
26
|
+
//# sourceMappingURL=useOfflineLessons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOfflineLessons.d.ts","sourceRoot":"","sources":["../../../src/technospam/hooks/useOfflineLessons.ts"],"names":[],"mappings":"AAmBA,yFAAyF;AACzF,wBAAgB,eAAe,IAAI,OAAO,CAYzC;AAED,MAAM,WAAW,eAAe;IAC9B,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+FAA+F;IAC/F,eAAe,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;CAClE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,8CAAoD,CAAC;AAEvF;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAI1E;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,CA6FrE"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { notify as e } from "../lib/toast.js";
|
|
2
|
+
import { sendQueuedLessonCompletion as t } from "../api/technospamApi.js";
|
|
3
|
+
import { flushCompletions as n, isDeviceOffline as r, markSnapshotCompleted as i, pendingCompletions as a, purgeOtherUsersLessons as o, queueLessonCompletion as s } from "../lib/offlineLessons.js";
|
|
4
|
+
import { useCallback as c, useEffect as l, useRef as u, useState as d } from "react";
|
|
5
|
+
import { useApolloClient as f } from "@apollo/client/react";
|
|
6
|
+
//#region src/technospam/hooks/useOfflineLessons.ts
|
|
7
|
+
function p() {
|
|
8
|
+
let [e, t] = d(() => !r());
|
|
9
|
+
return l(() => {
|
|
10
|
+
let e = () => t(!r());
|
|
11
|
+
return window.addEventListener("online", e), window.addEventListener("offline", e), () => {
|
|
12
|
+
window.removeEventListener("online", e), window.removeEventListener("offline", e);
|
|
13
|
+
};
|
|
14
|
+
}, []), e;
|
|
15
|
+
}
|
|
16
|
+
var m = [
|
|
17
|
+
5e3,
|
|
18
|
+
15e3,
|
|
19
|
+
3e4,
|
|
20
|
+
6e4,
|
|
21
|
+
12e4
|
|
22
|
+
];
|
|
23
|
+
function h(e) {
|
|
24
|
+
l(() => {
|
|
25
|
+
e && o(e);
|
|
26
|
+
}, [e]);
|
|
27
|
+
}
|
|
28
|
+
function g(r) {
|
|
29
|
+
let o = r, h = f(), g = p(), _ = u(null), v = u(o);
|
|
30
|
+
l(() => {
|
|
31
|
+
v.current = o;
|
|
32
|
+
}, [o]);
|
|
33
|
+
let [y, b] = d(0), x = u(0), S = u(null), C = c(() => ({
|
|
34
|
+
ws: o,
|
|
35
|
+
ids: a(o).map((e) => e.lessonId)
|
|
36
|
+
}), [o]), [w, T] = d(C), E = w.ws === o ? w.ids : C().ids, D = c(() => {
|
|
37
|
+
T(C());
|
|
38
|
+
}, [C]);
|
|
39
|
+
return l(() => {
|
|
40
|
+
x.current = 0;
|
|
41
|
+
}, [g, o]), l(() => () => {
|
|
42
|
+
S.current && clearTimeout(S.current);
|
|
43
|
+
}, []), l(() => {
|
|
44
|
+
if (!g || !o || _.current !== null || a(o).length === 0) return;
|
|
45
|
+
let r = o;
|
|
46
|
+
_.current = r, n(o, (e) => t(h, e.lessonId)).then((t) => {
|
|
47
|
+
t.rejected > 0 && e.warning("Some offline progress was not saved", `${t.rejected} lesson${t.rejected === 1 ? "" : "s"} could not be marked complete (removed, or ${t.rejected === 1 ? "it needs" : "they need"} a quiz or code check). Open ${t.rejected === 1 ? "it" : "them"} again to finish.`), t.sent > 0 && e.success("Progress synced", `${t.sent} lesson${t.sent === 1 ? "" : "s"} completed offline ${t.sent === 1 ? "is" : "are"} now saved.`);
|
|
48
|
+
let n = m[x.current];
|
|
49
|
+
t.remaining > 0 && n !== void 0 ? (x.current += 1, S.current && clearTimeout(S.current), S.current = setTimeout(() => b((e) => e + 1), n)) : t.remaining === 0 && (x.current = 0);
|
|
50
|
+
}).finally(() => {
|
|
51
|
+
_.current = null, D(), v.current !== r && b((e) => e + 1);
|
|
52
|
+
});
|
|
53
|
+
}, [
|
|
54
|
+
g,
|
|
55
|
+
o,
|
|
56
|
+
h,
|
|
57
|
+
D,
|
|
58
|
+
y
|
|
59
|
+
]), {
|
|
60
|
+
pendingLessonIds: E,
|
|
61
|
+
queueCompletion: c((e, t) => {
|
|
62
|
+
let n = s(o, e, t);
|
|
63
|
+
return n && i(o, e, t), D(), n;
|
|
64
|
+
}, [o, D])
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
export { g as useOfflineProgressSync, p as useOnlineStatus, h as usePurgeOtherUsersLessons };
|
|
69
|
+
|
|
70
|
+
//# sourceMappingURL=useOfflineLessons.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOfflineLessons.js","names":[],"sources":["../../../src/technospam/hooks/useOfflineLessons.ts"],"sourcesContent":["/**\n * React side of offline lesson reading and progress sync (BOFF-4418). The\n * storage rules live in `../lib/offlineLessons`; these hooks connect them to\n * the browser's connectivity events and the shell's Apollo client.\n */\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApolloClient } from '@apollo/client/react';\n\nimport { sendQueuedLessonCompletion } from '../api/technospamApi';\nimport { notify } from '../lib/toast';\nimport {\n flushCompletions,\n isDeviceOffline,\n markSnapshotCompleted,\n pendingCompletions,\n purgeOtherUsersLessons,\n queueLessonCompletion,\n} from '../lib/offlineLessons';\n\n/** Live connectivity, from `navigator.onLine` and the window's online/offline events. */\nexport function useOnlineStatus(): boolean {\n const [online, setOnline] = useState<boolean>(() => !isDeviceOffline());\n useEffect(() => {\n const update = (): void => setOnline(!isDeviceOffline());\n window.addEventListener('online', update);\n window.addEventListener('offline', update);\n return () => {\n window.removeEventListener('online', update);\n window.removeEventListener('offline', update);\n };\n }, []);\n return online;\n}\n\nexport interface OfflineProgress {\n /** Lesson ids completed on this device and not yet sent. */\n pendingLessonIds: string[];\n /** Queue a completion made without a connection. False when the device refused to store it. */\n queueCompletion: (courseId: string, lessonId: string) => boolean;\n}\n\n/** Delays before re-trying a flush that left completions queued while online. */\nexport const FLUSH_RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 120_000] as const;\n\n/**\n * Removes the saved lessons of any other user from this device whenever the\n * signed-in user changes, so a shared phone never shows one learner's saved\n * (possibly premium) lessons to the next.\n */\nexport function usePurgeOtherUsersLessons(userId: string | undefined): void {\n useEffect(() => {\n if (userId) purgeOtherUsersLessons(userId);\n }, [userId]);\n}\n\n/**\n * Sends queued lesson completions whenever the device is (or comes back)\n * online, and exposes what is still pending so the player can show a lesson\n * as complete before the server has heard about it.\n *\n * `scope` is {@link offlineScope}(workspaceId, userId): the outbox belongs to\n * one learner in one workspace and is only ever flushed under that session.\n */\nexport function useOfflineProgressSync(scope: string): OfflineProgress {\n const ws = scope;\n const client = useApolloClient();\n const online = useOnlineStatus();\n // The scope whose outbox is being flushed, or null when idle. A scope\n // change mid-flush re-runs the effect once the flush settles (see below).\n const flushing = useRef<string | null>(null);\n const currentScope = useRef(ws);\n useEffect(() => {\n currentScope.current = ws;\n }, [ws]);\n // Bumped to re-run the flush effect after a retryable failure (gateway not\n // reachable yet although the browser reports online). Bounded by\n // FLUSH_RETRY_DELAYS_MS; reset on every connectivity or workspace change.\n const [retryTick, setRetryTick] = useState(0);\n const retryAttempt = useRef(0);\n const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n // The outbox lives in storage; this state mirrors it for rendering and is\n // refreshed whenever this hook changes the outbox. It is tagged with the\n // workspace it was read for, so switching workspace never shows stale ids.\n const readPending = useCallback(\n (): { ws: string; ids: string[] } => ({\n ws,\n ids: pendingCompletions(ws).map((item) => item.lessonId),\n }),\n [ws]\n );\n const [pending, setPending] = useState(readPending);\n const pendingLessonIds = pending.ws === ws ? pending.ids : readPending().ids;\n\n const refreshPending = useCallback((): void => {\n setPending(readPending());\n }, [readPending]);\n\n useEffect(() => {\n retryAttempt.current = 0;\n }, [online, ws]);\n\n useEffect(\n () => () => {\n if (retryTimer.current) clearTimeout(retryTimer.current);\n },\n []\n );\n\n useEffect(() => {\n if (!online || !ws || flushing.current !== null) return;\n if (pendingCompletions(ws).length === 0) return;\n const flushedScope = ws;\n flushing.current = flushedScope;\n void flushCompletions(ws, (item) => sendQueuedLessonCompletion(client, item.lessonId))\n .then((result) => {\n if (result.rejected > 0) {\n notify.warning(\n 'Some offline progress was not saved',\n `${result.rejected} lesson${result.rejected === 1 ? '' : 's'} could not be marked complete (removed, or ${result.rejected === 1 ? 'it needs' : 'they need'} a quiz or code check). Open ${result.rejected === 1 ? 'it' : 'them'} again to finish.`\n );\n }\n if (result.sent > 0) {\n notify.success(\n 'Progress synced',\n `${result.sent} lesson${result.sent === 1 ? '' : 's'} completed offline ${result.sent === 1 ? 'is' : 'are'} now saved.`\n );\n }\n const delay = FLUSH_RETRY_DELAYS_MS[retryAttempt.current];\n if (result.remaining > 0 && delay !== undefined) {\n retryAttempt.current += 1;\n if (retryTimer.current) clearTimeout(retryTimer.current);\n retryTimer.current = setTimeout(() => setRetryTick((tick) => tick + 1), delay);\n } else if (result.remaining === 0) {\n retryAttempt.current = 0;\n }\n })\n .finally(() => {\n flushing.current = null;\n refreshPending();\n // The workspace or user changed while this flush ran: the effect run\n // for the new scope returned early, so trigger it again now.\n if (currentScope.current !== flushedScope) setRetryTick((tick) => tick + 1);\n });\n }, [online, ws, client, refreshPending, retryTick]);\n\n const queueCompletion = useCallback(\n (courseId: string, lessonId: string): boolean => {\n const stored = queueLessonCompletion(ws, courseId, lessonId);\n if (stored) markSnapshotCompleted(ws, courseId, lessonId);\n refreshPending();\n return stored;\n },\n [ws, refreshPending]\n );\n\n return { pendingLessonIds, queueCompletion };\n}\n"],"mappings":";;;;;;AAoBA,SAAgB,IAA2B;CACzC,IAAM,CAAC,GAAQ,KAAa,QAAwB,CAAC,GAAiB,CAAC;AAUvE,QATA,QAAgB;EACd,IAAM,UAAqB,EAAU,CAAC,GAAiB,CAAC;AAGxD,SAFA,OAAO,iBAAiB,UAAU,EAAO,EACzC,OAAO,iBAAiB,WAAW,EAAO,QAC7B;AAEX,GADA,OAAO,oBAAoB,UAAU,EAAO,EAC5C,OAAO,oBAAoB,WAAW,EAAO;;IAE9C,EAAE,CAAC,EACC;;AAWT,IAAa,IAAwB;CAAC;CAAO;CAAQ;CAAQ;CAAQ;CAAQ;AAO7E,SAAgB,EAA0B,GAAkC;AAC1E,SAAgB;AACd,EAAI,KAAQ,EAAuB,EAAO;IACzC,CAAC,EAAO,CAAC;;AAWd,SAAgB,EAAuB,GAAgC;CACrE,IAAM,IAAK,GACL,IAAS,GAAiB,EAC1B,IAAS,GAAiB,EAG1B,IAAW,EAAsB,KAAK,EACtC,IAAe,EAAO,EAAG;AAC/B,SAAgB;AACd,IAAa,UAAU;IACtB,CAAC,EAAG,CAAC;CAIR,IAAM,CAAC,GAAW,KAAgB,EAAS,EAAE,EACvC,IAAe,EAAO,EAAE,EACxB,IAAa,EAA6C,KAAK,EAI/D,IAAc,SACoB;EACpC;EACA,KAAK,EAAmB,EAAG,CAAC,KAAK,MAAS,EAAK,SAAS;EACzD,GACD,CAAC,EAAG,CACL,EACK,CAAC,GAAS,KAAc,EAAS,EAAY,EAC7C,IAAmB,EAAQ,OAAO,IAAK,EAAQ,MAAM,GAAa,CAAC,KAEnE,IAAiB,QAAwB;AAC7C,IAAW,GAAa,CAAC;IACxB,CAAC,EAAY,CAAC;AA4DjB,QA1DA,QAAgB;AACd,IAAa,UAAU;IACtB,CAAC,GAAQ,EAAG,CAAC,EAEhB,cACc;AACV,EAAI,EAAW,WAAS,aAAa,EAAW,QAAQ;IAE1D,EAAE,CACH,EAED,QAAgB;AAEd,MADI,CAAC,KAAU,CAAC,KAAM,EAAS,YAAY,QACvC,EAAmB,EAAG,CAAC,WAAW,EAAG;EACzC,IAAM,IAAe;AAEhB,EADL,EAAS,UAAU,GACd,EAAiB,IAAK,MAAS,EAA2B,GAAQ,EAAK,SAAS,CAAC,CACnF,MAAM,MAAW;AAOhB,GANI,EAAO,WAAW,KACpB,EAAO,QACL,uCACA,GAAG,EAAO,SAAS,SAAS,EAAO,aAAa,IAAI,KAAK,IAAI,6CAA6C,EAAO,aAAa,IAAI,aAAa,YAAY,+BAA+B,EAAO,aAAa,IAAI,OAAO,OAAO,mBACjO,EAEC,EAAO,OAAO,KAChB,EAAO,QACL,mBACA,GAAG,EAAO,KAAK,SAAS,EAAO,SAAS,IAAI,KAAK,IAAI,qBAAqB,EAAO,SAAS,IAAI,OAAO,MAAM,aAC5G;GAEH,IAAM,IAAQ,EAAsB,EAAa;AACjD,GAAI,EAAO,YAAY,KAAK,MAAU,KAAA,KACpC,EAAa,WAAW,GACpB,EAAW,WAAS,aAAa,EAAW,QAAQ,EACxD,EAAW,UAAU,iBAAiB,GAAc,MAAS,IAAO,EAAE,EAAE,EAAM,IACrE,EAAO,cAAc,MAC9B,EAAa,UAAU;IAEzB,CACD,cAAc;AAKb,GAJA,EAAS,UAAU,MACnB,GAAgB,EAGZ,EAAa,YAAY,KAAc,GAAc,MAAS,IAAO,EAAE;IAC3E;IACH;EAAC;EAAQ;EAAI;EAAQ;EAAgB;EAAU,CAAC,EAY5C;EAAE;EAAkB,iBAVH,GACrB,GAAkB,MAA8B;GAC/C,IAAM,IAAS,EAAsB,GAAI,GAAU,EAAS;AAG5D,UAFI,KAAQ,EAAsB,GAAI,GAAU,EAAS,EACzD,GAAgB,EACT;KAET,CAAC,GAAI,EAAe,CACrB;EAE2C"}
|
|
@@ -4,4 +4,6 @@ export { useTechnoSpam, useTechnoSpamNavigate } from './providers/TechnoSpamProv
|
|
|
4
4
|
export * from './services/createSandboxAssistantTransport';
|
|
5
5
|
export { useTr } from './hooks/useTr';
|
|
6
6
|
export { useTechnoSpamAnalytics } from './hooks/useTechnoSpamAnalytics';
|
|
7
|
+
export { CertificateVerifyPage } from './pages/public/CertificateVerifyPage';
|
|
8
|
+
export { CERTIFICATE_VERIFY_BASE_PATH, CERTIFICATE_VERIFY_ROUTE_PATH, certificateVerifyLink, certificateVerifyPath, } from './lib/certificateVerify';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/technospam/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,mBAAmB,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAOtF,cAAc,4CAA4C,CAAC;AAG3D,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/technospam/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,mBAAmB,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAOtF,cAAc,4CAA4C,CAAC;AAG3D,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAGtC,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAIxE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,EACL,4BAA4B,EAC5B,6BAA6B,EAC7B,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,yBAAyB,CAAC"}
|
package/dist/technospam/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import "./providers/TechnoSpamProvider.js";
|
|
2
2
|
import "./hooks/useTechnoSpamAnalytics.js";
|
|
3
3
|
import "./hooks/useTr.js";
|
|
4
|
+
import "./lib/certificateVerify.js";
|
|
4
5
|
import "./TechnoSpamRoot.js";
|
|
5
6
|
import "./services/createSandboxAssistantTransport.js";
|
|
7
|
+
import "./pages/public/CertificateVerifyPage.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public certificate verification page helpers (BOFF-10576).
|
|
3
|
+
*
|
|
4
|
+
* The page lets a recruiter open the link printed on a TechnoSpam certificate
|
|
5
|
+
* and confirm it without a Burdenoff account. It calls the public
|
|
6
|
+
* `tecVerifyCertificate` field (`@rbac(public: true)`, BOFF-4430), which
|
|
7
|
+
* returns the course, score, dates and status but never the learner's
|
|
8
|
+
* identity or a revoke reason.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the route constants that are relative to this MFE's `basePath`, the
|
|
11
|
+
* verification route is absolute: the host shell mounts the page on its own
|
|
12
|
+
* top-level router, outside the auth guard. It lives under `/public/` because
|
|
13
|
+
* fe-libs' shell-route table already treats every `/public/*` path as a
|
|
14
|
+
* public, sidebar-less page.
|
|
15
|
+
*/
|
|
16
|
+
/** Base path of the public verification page. */
|
|
17
|
+
export declare const CERTIFICATE_VERIFY_BASE_PATH = "/public/verify";
|
|
18
|
+
/** For the host's `<Route path={CERTIFICATE_VERIFY_ROUTE_PATH} ... />`. */
|
|
19
|
+
export declare const CERTIFICATE_VERIFY_ROUTE_PATH = "/public/verify/:serial";
|
|
20
|
+
/** Mirrors the service's MAX_SERIAL_LENGTH guard; longer input is never sent. */
|
|
21
|
+
export declare const MAX_SERIAL_LENGTH = 120;
|
|
22
|
+
export type CertificateVerificationStatus = 'VALID' | 'REVOKED' | 'NOT_FOUND';
|
|
23
|
+
export interface CertificateVerification {
|
|
24
|
+
serial: string;
|
|
25
|
+
status: CertificateVerificationStatus;
|
|
26
|
+
valid: boolean;
|
|
27
|
+
issuer: string;
|
|
28
|
+
courseTitle: string | null;
|
|
29
|
+
courseLevel: string | null;
|
|
30
|
+
score: number | null;
|
|
31
|
+
issuedAt: string | null;
|
|
32
|
+
revokedAt: string | null;
|
|
33
|
+
verifiedAt: string;
|
|
34
|
+
}
|
|
35
|
+
export type VerifyOutcome = {
|
|
36
|
+
kind: 'result';
|
|
37
|
+
verification: CertificateVerification;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'invalid';
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'error';
|
|
42
|
+
};
|
|
43
|
+
/** Normalize a serial the way the service does: trim and upper-case. */
|
|
44
|
+
export declare function normalizeSerial(raw: string | null | undefined): string;
|
|
45
|
+
/** True when a normalized serial is worth sending to the service at all. */
|
|
46
|
+
export declare function isPlausibleSerial(serial: string): boolean;
|
|
47
|
+
/** Relative path of the verification page for one serial. */
|
|
48
|
+
export declare function certificateVerifyPath(serial: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The full, shareable verification link for a serial. Defaults to the current
|
|
51
|
+
* origin, which is the product's app shell (for example
|
|
52
|
+
* `https://app.technospam.com`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function certificateVerifyLink(serial: string, origin?: string): string;
|
|
55
|
+
export declare const VERIFY_QUERY = "\n query PublicTecVerifyCertificate($serial: String!) {\n tecVerifyCertificate(serial: $serial) {\n serial\n status\n valid\n issuer\n courseTitle\n courseLevel\n score\n issuedAt\n revokedAt\n verifiedAt\n }\n }\n";
|
|
56
|
+
/**
|
|
57
|
+
* The GraphQL endpoint for a gateway URL. Hosts pass either the workspace
|
|
58
|
+
* gateway origin (the app shell's `workspaceBaseUrl`) or a complete GraphQL
|
|
59
|
+
* endpoint (the dev harness and stories); a URL that already ends in
|
|
60
|
+
* `/graphql` is used as is.
|
|
61
|
+
*/
|
|
62
|
+
export declare function verificationEndpoint(apiGatewayUrl: string | undefined): string;
|
|
63
|
+
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
64
|
+
/**
|
|
65
|
+
* Verify one serial against the public workspace gateway. The request is
|
|
66
|
+
* deliberately anonymous: no Authorization header and no cookies, so the
|
|
67
|
+
* viewer's own session (if any) is never attached to a public lookup.
|
|
68
|
+
*/
|
|
69
|
+
export declare function fetchCertificateVerification(apiGatewayUrl: string | undefined, rawSerial: string, fetchImpl?: FetchLike): Promise<VerifyOutcome>;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=certificateVerify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"certificateVerify.d.ts","sourceRoot":"","sources":["../../../src/technospam/lib/certificateVerify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,iDAAiD;AACjD,eAAO,MAAM,4BAA4B,mBAAmB,CAAC;AAE7D,2EAA2E;AAC3E,eAAO,MAAM,6BAA6B,2BAA4C,CAAC;AAEvF,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC,MAAM,MAAM,6BAA6B,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE9E,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,6BAA6B,CAAC;IACtC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,YAAY,EAAE,uBAAuB,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAEtE;AAED,4EAA4E;AAC5E,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAIzD;AAED,6DAA6D;AAC7D,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAG7E;AAED,eAAO,MAAM,YAAY,qRAexB,CAAC;AAgBF;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAG9E;AAED,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEzE;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,SAA+C,GACzD,OAAO,CAAC,aAAa,CAAC,CAuBxB"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/technospam/lib/certificateVerify.ts
|
|
2
|
+
var e = "/public/verify", t = `${e}/:serial`;
|
|
3
|
+
function n(e) {
|
|
4
|
+
return (e ?? "").trim().toUpperCase();
|
|
5
|
+
}
|
|
6
|
+
function r(e) {
|
|
7
|
+
return e.length > 0 && e.length <= 120 && /^[A-Z0-9][A-Z0-9._-]*$/.test(e);
|
|
8
|
+
}
|
|
9
|
+
function i(t) {
|
|
10
|
+
return `${e}/${encodeURIComponent(n(t))}`;
|
|
11
|
+
}
|
|
12
|
+
function a(e, t) {
|
|
13
|
+
return `${(t ?? (typeof window < "u" ? window.location.origin : "")).replace(/\/+$/, "")}${i(e)}`;
|
|
14
|
+
}
|
|
15
|
+
var o = "\n query PublicTecVerifyCertificate($serial: String!) {\n tecVerifyCertificate(serial: $serial) {\n serial\n status\n valid\n issuer\n courseTitle\n courseLevel\n score\n issuedAt\n revokedAt\n verifiedAt\n }\n }\n", s = new Set([
|
|
16
|
+
"VALID",
|
|
17
|
+
"REVOKED",
|
|
18
|
+
"NOT_FOUND"
|
|
19
|
+
]);
|
|
20
|
+
function c(e) {
|
|
21
|
+
if (!e || typeof e != "object") return !1;
|
|
22
|
+
let t = e;
|
|
23
|
+
return typeof t.serial == "string" && typeof t.status == "string" && s.has(t.status) && typeof t.valid == "boolean" && typeof t.verifiedAt == "string";
|
|
24
|
+
}
|
|
25
|
+
function l(e) {
|
|
26
|
+
let t = (e ?? "").replace(/\/+$/, "");
|
|
27
|
+
return /\/graphql$/.test(t) ? t : `${t}/workspaces/graphql`;
|
|
28
|
+
}
|
|
29
|
+
async function u(e, t, i = (e, t) => fetch(e, t)) {
|
|
30
|
+
let a = n(t);
|
|
31
|
+
if (!r(a)) return { kind: "invalid" };
|
|
32
|
+
let s = l(e), u;
|
|
33
|
+
try {
|
|
34
|
+
u = await i(s, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
credentials: "omit",
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
query: o,
|
|
40
|
+
variables: { serial: a }
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
return { kind: "error" };
|
|
45
|
+
}
|
|
46
|
+
let d = (await u.json().catch(() => null))?.data?.tecVerifyCertificate;
|
|
47
|
+
return !u.ok || !c(d) ? { kind: "error" } : {
|
|
48
|
+
kind: "result",
|
|
49
|
+
verification: d
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { e as CERTIFICATE_VERIFY_BASE_PATH, t as CERTIFICATE_VERIFY_ROUTE_PATH, a as certificateVerifyLink, i as certificateVerifyPath, u as fetchCertificateVerification, r as isPlausibleSerial, n as normalizeSerial };
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=certificateVerify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"certificateVerify.js","names":[],"sources":["../../../src/technospam/lib/certificateVerify.ts"],"sourcesContent":["/**\n * Public certificate verification page helpers (BOFF-10576).\n *\n * The page lets a recruiter open the link printed on a TechnoSpam certificate\n * and confirm it without a Burdenoff account. It calls the public\n * `tecVerifyCertificate` field (`@rbac(public: true)`, BOFF-4430), which\n * returns the course, score, dates and status but never the learner's\n * identity or a revoke reason.\n *\n * Unlike the route constants that are relative to this MFE's `basePath`, the\n * verification route is absolute: the host shell mounts the page on its own\n * top-level router, outside the auth guard. It lives under `/public/` because\n * fe-libs' shell-route table already treats every `/public/*` path as a\n * public, sidebar-less page.\n */\n\n/** Base path of the public verification page. */\nexport const CERTIFICATE_VERIFY_BASE_PATH = '/public/verify';\n\n/** For the host's `<Route path={CERTIFICATE_VERIFY_ROUTE_PATH} ... />`. */\nexport const CERTIFICATE_VERIFY_ROUTE_PATH = `${CERTIFICATE_VERIFY_BASE_PATH}/:serial`;\n\n/** Mirrors the service's MAX_SERIAL_LENGTH guard; longer input is never sent. */\nexport const MAX_SERIAL_LENGTH = 120;\n\nexport type CertificateVerificationStatus = 'VALID' | 'REVOKED' | 'NOT_FOUND';\n\nexport interface CertificateVerification {\n serial: string;\n status: CertificateVerificationStatus;\n valid: boolean;\n issuer: string;\n courseTitle: string | null;\n courseLevel: string | null;\n score: number | null;\n issuedAt: string | null;\n revokedAt: string | null;\n verifiedAt: string;\n}\n\nexport type VerifyOutcome =\n | { kind: 'result'; verification: CertificateVerification }\n | { kind: 'invalid' }\n | { kind: 'error' };\n\n/** Normalize a serial the way the service does: trim and upper-case. */\nexport function normalizeSerial(raw: string | null | undefined): string {\n return (raw ?? '').trim().toUpperCase();\n}\n\n/** True when a normalized serial is worth sending to the service at all. */\nexport function isPlausibleSerial(serial: string): boolean {\n return (\n serial.length > 0 && serial.length <= MAX_SERIAL_LENGTH && /^[A-Z0-9][A-Z0-9._-]*$/.test(serial)\n );\n}\n\n/** Relative path of the verification page for one serial. */\nexport function certificateVerifyPath(serial: string): string {\n return `${CERTIFICATE_VERIFY_BASE_PATH}/${encodeURIComponent(normalizeSerial(serial))}`;\n}\n\n/**\n * The full, shareable verification link for a serial. Defaults to the current\n * origin, which is the product's app shell (for example\n * `https://app.technospam.com`).\n */\nexport function certificateVerifyLink(serial: string, origin?: string): string {\n const base = origin ?? (typeof window !== 'undefined' ? window.location.origin : '');\n return `${base.replace(/\\/+$/, '')}${certificateVerifyPath(serial)}`;\n}\n\nexport const VERIFY_QUERY = `\n query PublicTecVerifyCertificate($serial: String!) {\n tecVerifyCertificate(serial: $serial) {\n serial\n status\n valid\n issuer\n courseTitle\n courseLevel\n score\n issuedAt\n revokedAt\n verifiedAt\n }\n }\n`;\n\nconst STATUSES: ReadonlySet<string> = new Set(['VALID', 'REVOKED', 'NOT_FOUND']);\n\nfunction isVerification(value: unknown): value is CertificateVerification {\n if (!value || typeof value !== 'object') return false;\n const v = value as Record<string, unknown>;\n return (\n typeof v.serial === 'string' &&\n typeof v.status === 'string' &&\n STATUSES.has(v.status) &&\n typeof v.valid === 'boolean' &&\n typeof v.verifiedAt === 'string'\n );\n}\n\n/**\n * The GraphQL endpoint for a gateway URL. Hosts pass either the workspace\n * gateway origin (the app shell's `workspaceBaseUrl`) or a complete GraphQL\n * endpoint (the dev harness and stories); a URL that already ends in\n * `/graphql` is used as is.\n */\nexport function verificationEndpoint(apiGatewayUrl: string | undefined): string {\n const base = (apiGatewayUrl ?? '').replace(/\\/+$/, '');\n return /\\/graphql$/.test(base) ? base : `${base}/workspaces/graphql`;\n}\n\ntype FetchLike = (input: string, init: RequestInit) => Promise<Response>;\n\n/**\n * Verify one serial against the public workspace gateway. The request is\n * deliberately anonymous: no Authorization header and no cookies, so the\n * viewer's own session (if any) is never attached to a public lookup.\n */\nexport async function fetchCertificateVerification(\n apiGatewayUrl: string | undefined,\n rawSerial: string,\n fetchImpl: FetchLike = (input, init) => fetch(input, init)\n): Promise<VerifyOutcome> {\n const serial = normalizeSerial(rawSerial);\n if (!isPlausibleSerial(serial)) return { kind: 'invalid' };\n\n const endpoint = verificationEndpoint(apiGatewayUrl);\n let response: Response;\n try {\n response = await fetchImpl(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n credentials: 'omit',\n body: JSON.stringify({ query: VERIFY_QUERY, variables: { serial } }),\n });\n } catch {\n return { kind: 'error' };\n }\n\n const body = (await response.json().catch(() => null)) as {\n data?: { tecVerifyCertificate?: unknown } | null;\n } | null;\n const verification = body?.data?.tecVerifyCertificate;\n if (!response.ok || !isVerification(verification)) return { kind: 'error' };\n return { kind: 'result', verification };\n}\n"],"mappings":";AAiBA,IAAa,IAA+B,kBAG/B,IAAgC,GAAG,EAA6B;AA0B7E,SAAgB,EAAgB,GAAwC;AACtE,SAAQ,KAAO,IAAI,MAAM,CAAC,aAAa;;AAIzC,SAAgB,EAAkB,GAAyB;AACzD,QACE,EAAO,SAAS,KAAK,EAAO,UAAA,OAA+B,yBAAyB,KAAK,EAAO;;AAKpG,SAAgB,EAAsB,GAAwB;AAC5D,QAAO,GAAG,EAA6B,GAAG,mBAAmB,EAAgB,EAAO,CAAC;;AAQvF,SAAgB,EAAsB,GAAgB,GAAyB;AAE7E,QAAO,IADM,MAAW,OAAO,SAAW,MAAc,OAAO,SAAS,SAAS,KAClE,QAAQ,QAAQ,GAAG,GAAG,EAAsB,EAAO;;AAGpE,IAAa,IAAe,oRAiBtB,IAAgC,IAAI,IAAI;CAAC;CAAS;CAAW;CAAY,CAAC;AAEhF,SAAS,EAAe,GAAkD;AACxE,KAAI,CAAC,KAAS,OAAO,KAAU,SAAU,QAAO;CAChD,IAAM,IAAI;AACV,QACE,OAAO,EAAE,UAAW,YACpB,OAAO,EAAE,UAAW,YACpB,EAAS,IAAI,EAAE,OAAO,IACtB,OAAO,EAAE,SAAU,aACnB,OAAO,EAAE,cAAe;;AAU5B,SAAgB,EAAqB,GAA2C;CAC9E,IAAM,KAAQ,KAAiB,IAAI,QAAQ,QAAQ,GAAG;AACtD,QAAO,aAAa,KAAK,EAAK,GAAG,IAAO,GAAG,EAAK;;AAUlD,eAAsB,EACpB,GACA,GACA,KAAwB,GAAO,MAAS,MAAM,GAAO,EAAK,EAClC;CACxB,IAAM,IAAS,EAAgB,EAAU;AACzC,KAAI,CAAC,EAAkB,EAAO,CAAE,QAAO,EAAE,MAAM,WAAW;CAE1D,IAAM,IAAW,EAAqB,EAAc,EAChD;AACJ,KAAI;AACF,MAAW,MAAM,EAAU,GAAU;GACnC,QAAQ;GACR,SAAS,EAAE,gBAAgB,oBAAoB;GAC/C,aAAa;GACb,MAAM,KAAK,UAAU;IAAE,OAAO;IAAc,WAAW,EAAE,WAAQ;IAAE,CAAC;GACrE,CAAC;SACI;AACN,SAAO,EAAE,MAAM,SAAS;;CAM1B,IAAM,KAHQ,MAAM,EAAS,MAAM,CAAC,YAAY,KAAK,GAG1B,MAAM;AAEjC,QADI,CAAC,EAAS,MAAM,CAAC,EAAe,EAAa,GAAS,EAAE,MAAM,SAAS,GACpE;EAAE,MAAM;EAAU;EAAc"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { LessonPlayerData, TecCourse, TecLesson } from '../api/technospamApi';
|
|
2
|
+
|
|
3
|
+
/** Most lesson snapshots kept per scope (workspace + user); the least recently saved are evicted first. */
|
|
4
|
+
export declare const MAX_SAVED_LESSONS = 150;
|
|
5
|
+
export interface SnapshotEnrollment {
|
|
6
|
+
id: string;
|
|
7
|
+
progressPct: number;
|
|
8
|
+
}
|
|
9
|
+
export interface LessonSnapshot {
|
|
10
|
+
savedAt: string;
|
|
11
|
+
lesson: TecLesson;
|
|
12
|
+
course: TecCourse;
|
|
13
|
+
enrollment: SnapshotEnrollment | null;
|
|
14
|
+
}
|
|
15
|
+
export interface QueuedCompletion {
|
|
16
|
+
lessonId: string;
|
|
17
|
+
courseId: string;
|
|
18
|
+
queuedAt: string;
|
|
19
|
+
}
|
|
20
|
+
/** The subset of the Web Storage API this module needs (injectable for tests). */
|
|
21
|
+
export type KeyValueStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
22
|
+
/**
|
|
23
|
+
* Storage scope for one learner in one workspace. Empty (nothing is read or
|
|
24
|
+
* stored) until both are known, so data is never written under a partial key.
|
|
25
|
+
*/
|
|
26
|
+
export declare function offlineScope(workspaceId: string | null | undefined, userId: string | null | undefined): string;
|
|
27
|
+
/** True when the browser reports no network connection. */
|
|
28
|
+
export declare function isDeviceOffline(): boolean;
|
|
29
|
+
/** Whether a lesson payload can be kept for offline reading. */
|
|
30
|
+
export declare function isSnapshotable(data: LessonPlayerData | undefined | null): data is {
|
|
31
|
+
tecLesson: TecLesson;
|
|
32
|
+
tecCourse: TecCourse;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Store (or refresh) one lesson snapshot. A signed download URL expires, so
|
|
36
|
+
* the video file reference is dropped: an offline video cannot play anyway.
|
|
37
|
+
* Returns false when nothing was stored (locked lesson or storage refused).
|
|
38
|
+
*/
|
|
39
|
+
export declare function saveLessonSnapshot(scope: string, data: LessonPlayerData, enrollment: SnapshotEnrollment | null, storage?: KeyValueStorage | null, now?: Date): boolean;
|
|
40
|
+
/** Read one lesson snapshot, or null when it was never saved. */
|
|
41
|
+
export declare function readLessonSnapshot(scope: string, courseId: string, lessonId: string, storage?: KeyValueStorage | null): LessonSnapshot | null;
|
|
42
|
+
/**
|
|
43
|
+
* Mark a saved lesson as completed by the learner, so reopening it offline
|
|
44
|
+
* shows it as done even before the queued completion reaches the server.
|
|
45
|
+
*/
|
|
46
|
+
export declare function markSnapshotCompleted(scope: string, courseId: string, lessonId: string, storage?: KeyValueStorage | null): void;
|
|
47
|
+
/** The parts of the Web Storage API needed to enumerate keys. */
|
|
48
|
+
export type EnumerableStorage = KeyValueStorage & Pick<Storage, 'key' | 'length'>;
|
|
49
|
+
/**
|
|
50
|
+
* Remove every saved lesson (snapshots and their index) that belongs to a user
|
|
51
|
+
* other than `userId`. Queued completions are kept: they hold only lesson ids,
|
|
52
|
+
* are scoped to their own user and sync when that user signs in again.
|
|
53
|
+
* Returns how many keys were removed.
|
|
54
|
+
*/
|
|
55
|
+
export declare function purgeOtherUsersLessons(userId: string, storage?: EnumerableStorage | null): number;
|
|
56
|
+
/** Ids of the lessons of one course saved on this device. */
|
|
57
|
+
export declare function savedLessonIds(scope: string, courseId: string, storage?: KeyValueStorage | null): string[];
|
|
58
|
+
/** A course rebuilt from the lessons saved on this device. */
|
|
59
|
+
export interface SavedCourseOutline {
|
|
60
|
+
course: TecCourse;
|
|
61
|
+
/** Saved lessons of the course, in curriculum order. */
|
|
62
|
+
lessons: TecLesson[];
|
|
63
|
+
/** Enrollment progress as of the most recently saved lesson, if any. */
|
|
64
|
+
enrollment: SnapshotEnrollment | null;
|
|
65
|
+
savedAt: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Rebuild a course outline from its saved lesson snapshots, so the course
|
|
69
|
+
* page can list the saved lessons when it cannot reach the server (a cold
|
|
70
|
+
* start offline has no Apollo cache). The course header comes from the most
|
|
71
|
+
* recently saved snapshot. Returns null when no lesson of the course is saved.
|
|
72
|
+
*/
|
|
73
|
+
export declare function readSavedCourseOutline(scope: string, courseId: string, storage?: KeyValueStorage | null): SavedCourseOutline | null;
|
|
74
|
+
/** Remove every saved lesson of one course from this device. */
|
|
75
|
+
export declare function removeSavedCourse(scope: string, courseId: string, storage?: KeyValueStorage | null): void;
|
|
76
|
+
/** Completions waiting to be sent. */
|
|
77
|
+
export declare function pendingCompletions(scope: string, storage?: KeyValueStorage | null): QueuedCompletion[];
|
|
78
|
+
/** Queue a lesson completion for the next sync. Queuing the same lesson twice is a no-op. */
|
|
79
|
+
export declare function queueLessonCompletion(scope: string, courseId: string, lessonId: string, storage?: KeyValueStorage | null, now?: Date): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Outcome of sending one queued completion: `sent` and `rejected` (the server
|
|
82
|
+
* refused it for good, e.g. the lesson was removed) both leave the queue;
|
|
83
|
+
* `retry` (no connection, gateway error) stays for the next sync.
|
|
84
|
+
*/
|
|
85
|
+
export type CompletionSendOutcome = 'sent' | 'rejected' | 'retry';
|
|
86
|
+
export interface FlushResult {
|
|
87
|
+
sent: number;
|
|
88
|
+
rejected: number;
|
|
89
|
+
remaining: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Send queued completions in order. Stops at the first `retry`, since that
|
|
93
|
+
* means the connection is not usable yet, and keeps it and everything after
|
|
94
|
+
* it queued.
|
|
95
|
+
*/
|
|
96
|
+
export declare function flushCompletions(scope: string, send: (item: QueuedCompletion) => Promise<CompletionSendOutcome>, storage?: KeyValueStorage | null): Promise<FlushResult>;
|
|
97
|
+
//# sourceMappingURL=offlineLessons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offlineLessons.d.ts","sourceRoot":"","sources":["../../../src/technospam/lib/offlineLessons.ts"],"names":[],"mappings":"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"}
|