@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
|
@@ -7,190 +7,198 @@ import { formatDate as o, relativeTime as s } from "../utils/format.js";
|
|
|
7
7
|
import { StatusBadge as c } from "../components/StatusBadge.js";
|
|
8
8
|
import { EmptyView as l, ErrorView as u, LoadingRows as d } from "../components/StateViews.js";
|
|
9
9
|
import { useMyCertificatesQuery as f, useMyEnrollmentsQuery as p } from "../api/technospamApi.js";
|
|
10
|
+
import { CertificateVerifyLink as m } from "../components/CertificateVerifyLink.js";
|
|
10
11
|
import "../components/index.js";
|
|
11
|
-
import { FOCUS_PARAMS as
|
|
12
|
-
import { resumePath as
|
|
13
|
-
import { focusableCardProps as
|
|
14
|
-
import { createElement as
|
|
15
|
-
import { Award as
|
|
16
|
-
import { jsx as
|
|
17
|
-
import { Button as
|
|
12
|
+
import { FOCUS_PARAMS as h } from "../utils/pageContext.js";
|
|
13
|
+
import { resumePath as g } from "../utils/lessonPath.js";
|
|
14
|
+
import { focusableCardProps as _, useFocusedRecordParam as v } from "../hooks/useFocusedRecordParam.js";
|
|
15
|
+
import { createElement as y } from "react";
|
|
16
|
+
import { Award as b, BookOpen as x, GraduationCap as S, PlayCircle as C, ShieldCheck as w } from "lucide-react";
|
|
17
|
+
import { jsx as T, jsxs as E } from "react/jsx-runtime";
|
|
18
|
+
import { Button as D, GlassCard as O } from "@burdenoff/fe-libs/ui";
|
|
18
19
|
//#region src/technospam/pages/CoursesMyLearningPagePage.tsx
|
|
19
|
-
function
|
|
20
|
+
function k({ e, onOpen: n, focused: r, onToggle: o }) {
|
|
20
21
|
let l = `${e.course?.topic ?? ""} ${e.course?.title ?? "Course"}`;
|
|
21
|
-
return /* @__PURE__ */
|
|
22
|
+
return /* @__PURE__ */ E(O, {
|
|
22
23
|
className: t("group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:flex-row sm:items-center sm:gap-4", r && "ring-2 ring-accent-primary"),
|
|
23
|
-
...
|
|
24
|
+
..._(r, o),
|
|
24
25
|
children: [
|
|
25
|
-
/* @__PURE__ */
|
|
26
|
+
/* @__PURE__ */ T("span", {
|
|
26
27
|
className: t("flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-sm transition-transform duration-200 group-hover:scale-105", i(l)),
|
|
27
|
-
children:
|
|
28
|
+
children: y(a(l), { size: 20 })
|
|
28
29
|
}),
|
|
29
|
-
/* @__PURE__ */
|
|
30
|
+
/* @__PURE__ */ E("div", {
|
|
30
31
|
className: "min-w-0 flex-1",
|
|
31
32
|
children: [
|
|
32
|
-
/* @__PURE__ */
|
|
33
|
+
/* @__PURE__ */ E("div", {
|
|
33
34
|
className: "flex flex-wrap items-center gap-2",
|
|
34
|
-
children: [/* @__PURE__ */
|
|
35
|
+
children: [/* @__PURE__ */ T("button", {
|
|
35
36
|
type: "button",
|
|
36
37
|
"aria-pressed": r,
|
|
37
38
|
onClick: o,
|
|
38
39
|
className: "truncate text-left text-sm font-semibold text-text-primary group-hover:text-accent-primary",
|
|
39
40
|
children: e.course?.title ?? "Course"
|
|
40
|
-
}), /* @__PURE__ */
|
|
41
|
+
}), /* @__PURE__ */ T(c, { status: e.status })]
|
|
41
42
|
}),
|
|
42
|
-
/* @__PURE__ */
|
|
43
|
+
/* @__PURE__ */ E("div", {
|
|
43
44
|
className: "mt-1 flex flex-wrap items-center gap-2 text-xs text-text-muted",
|
|
44
45
|
children: [
|
|
45
|
-
/* @__PURE__ */
|
|
46
|
-
/* @__PURE__ */
|
|
46
|
+
/* @__PURE__ */ E("span", { children: [e.lessonsCompleted, " lessons done"] }),
|
|
47
|
+
/* @__PURE__ */ T("span", {
|
|
47
48
|
"aria-hidden": !0,
|
|
48
49
|
children: "·"
|
|
49
50
|
}),
|
|
50
|
-
/* @__PURE__ */
|
|
51
|
+
/* @__PURE__ */ E("span", { children: ["Active ", s(e.lastActiveAt ?? e.startedAt)] })
|
|
51
52
|
]
|
|
52
53
|
}),
|
|
53
|
-
/* @__PURE__ */
|
|
54
|
+
/* @__PURE__ */ T("div", {
|
|
54
55
|
className: "mt-2 h-1.5 w-full overflow-hidden rounded-full bg-bg-sunken",
|
|
55
|
-
children: /* @__PURE__ */
|
|
56
|
+
children: /* @__PURE__ */ T("div", {
|
|
56
57
|
className: "h-full rounded-full bg-gradient-to-r from-accent-primary via-accent-indigo to-accent-purple transition-all duration-500",
|
|
57
58
|
style: { width: `${e.progressPct}%` }
|
|
58
59
|
})
|
|
59
60
|
})
|
|
60
61
|
]
|
|
61
62
|
}),
|
|
62
|
-
/* @__PURE__ */
|
|
63
|
+
/* @__PURE__ */ E("div", {
|
|
63
64
|
className: "flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:justify-center",
|
|
64
|
-
children: [/* @__PURE__ */
|
|
65
|
+
children: [/* @__PURE__ */ E("div", {
|
|
65
66
|
className: "text-lg font-semibold tabular-nums text-text-primary",
|
|
66
67
|
children: [e.progressPct, "%"]
|
|
67
|
-
}), /* @__PURE__ */
|
|
68
|
+
}), /* @__PURE__ */ E(D, {
|
|
68
69
|
variant: "ghost",
|
|
69
70
|
size: "sm",
|
|
70
71
|
onClick: n,
|
|
71
72
|
"data-testid": `resume-${e.courseId}`,
|
|
72
|
-
children: [/* @__PURE__ */
|
|
73
|
+
children: [/* @__PURE__ */ T(C, { size: 15 }), " Resume"]
|
|
73
74
|
})]
|
|
74
75
|
})
|
|
75
76
|
]
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
|
-
var
|
|
79
|
-
function
|
|
80
|
-
let i = e(), { enrollments: a, loading: s, error:
|
|
81
|
-
return /* @__PURE__ */
|
|
79
|
+
var A = [h.certificate], j = [h.enrollment];
|
|
80
|
+
function M() {
|
|
81
|
+
let i = e(), { enrollments: a, loading: s, error: y } = p(), { certificates: M } = f(), [N, P] = v(h.enrollment, A), [F, I] = v(h.certificate, j), L = a.filter((e) => e.status === "COMPLETED").length, R = a.filter((e) => e.status === "IN_PROGRESS" || e.status === "ENROLLED").length;
|
|
82
|
+
return /* @__PURE__ */ E(n, {
|
|
82
83
|
hero: !0,
|
|
83
84
|
title: "My Learning",
|
|
84
85
|
description: "Track your enrolled courses, progress, and earned certificates.",
|
|
85
|
-
actions: /* @__PURE__ */
|
|
86
|
+
actions: /* @__PURE__ */ E(D, {
|
|
86
87
|
variant: "outline",
|
|
87
88
|
onClick: () => i("/courses/courses-page"),
|
|
88
|
-
children: [/* @__PURE__ */
|
|
89
|
+
children: [/* @__PURE__ */ T(x, { size: 16 }), " Browse catalog"]
|
|
89
90
|
}),
|
|
90
91
|
children: [
|
|
91
|
-
/* @__PURE__ */
|
|
92
|
+
/* @__PURE__ */ E("div", {
|
|
92
93
|
className: "mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4",
|
|
93
94
|
children: [
|
|
94
|
-
/* @__PURE__ */
|
|
95
|
+
/* @__PURE__ */ T(r, {
|
|
95
96
|
label: "Enrolled",
|
|
96
97
|
value: a.length,
|
|
97
98
|
hint: "Courses you've enrolled in",
|
|
98
|
-
icon:
|
|
99
|
+
icon: x,
|
|
99
100
|
tone: "blue"
|
|
100
101
|
}),
|
|
101
|
-
/* @__PURE__ */
|
|
102
|
+
/* @__PURE__ */ T(r, {
|
|
102
103
|
label: "In progress",
|
|
103
|
-
value:
|
|
104
|
+
value: R,
|
|
104
105
|
hint: "Courses in progress",
|
|
105
|
-
icon:
|
|
106
|
+
icon: C,
|
|
106
107
|
tone: "indigo"
|
|
107
108
|
}),
|
|
108
|
-
/* @__PURE__ */
|
|
109
|
+
/* @__PURE__ */ T(r, {
|
|
109
110
|
label: "Completed",
|
|
110
|
-
value:
|
|
111
|
+
value: L,
|
|
111
112
|
hint: "Courses completed",
|
|
112
|
-
icon:
|
|
113
|
+
icon: S,
|
|
113
114
|
tone: "green"
|
|
114
115
|
}),
|
|
115
|
-
/* @__PURE__ */
|
|
116
|
+
/* @__PURE__ */ T(r, {
|
|
116
117
|
label: "Certificates",
|
|
117
|
-
value:
|
|
118
|
+
value: M.length,
|
|
118
119
|
hint: "Certificates earned",
|
|
119
|
-
icon:
|
|
120
|
+
icon: b,
|
|
120
121
|
tone: "purple"
|
|
121
122
|
})
|
|
122
123
|
]
|
|
123
124
|
}),
|
|
124
|
-
s && !a.length ? /* @__PURE__ */
|
|
125
|
+
s && !a.length ? /* @__PURE__ */ T(d, {
|
|
125
126
|
count: 3,
|
|
126
127
|
height: "h-24"
|
|
127
|
-
}) :
|
|
128
|
+
}) : y ? /* @__PURE__ */ T(u, {
|
|
128
129
|
title: "Couldn't load your learning",
|
|
129
|
-
error:
|
|
130
|
-
}) : a.length === 0 ? /* @__PURE__ */
|
|
131
|
-
icon: /* @__PURE__ */
|
|
130
|
+
error: y
|
|
131
|
+
}) : a.length === 0 ? /* @__PURE__ */ T(l, {
|
|
132
|
+
icon: /* @__PURE__ */ T(x, { size: 26 }),
|
|
132
133
|
title: "You haven't enrolled yet",
|
|
133
134
|
description: "Browse the catalog and enroll in a course to start building skill by doing.",
|
|
134
|
-
action: /* @__PURE__ */
|
|
135
|
+
action: /* @__PURE__ */ T(D, {
|
|
135
136
|
onClick: () => i("/courses/courses-page"),
|
|
136
137
|
children: "Browse courses"
|
|
137
138
|
})
|
|
138
|
-
}) : /* @__PURE__ */
|
|
139
|
+
}) : /* @__PURE__ */ T("div", {
|
|
139
140
|
className: "space-y-3",
|
|
140
|
-
children: a.map((e) => /* @__PURE__ */
|
|
141
|
+
children: a.map((e) => /* @__PURE__ */ T(k, {
|
|
141
142
|
e,
|
|
142
|
-
onOpen: () => i(
|
|
143
|
-
focused:
|
|
144
|
-
onToggle: () => N
|
|
143
|
+
onOpen: () => i(g(e.courseId, e.nextLessonId)),
|
|
144
|
+
focused: N === e.id,
|
|
145
|
+
onToggle: () => P(N === e.id ? null : e.id)
|
|
145
146
|
}, e.id))
|
|
146
147
|
}),
|
|
147
|
-
|
|
148
|
+
M.length > 0 ? /* @__PURE__ */ E("div", {
|
|
148
149
|
className: "mt-8",
|
|
149
|
-
children: [/* @__PURE__ */
|
|
150
|
+
children: [/* @__PURE__ */ E("h2", {
|
|
150
151
|
className: "mb-3 flex items-center gap-2 text-base font-semibold text-text-primary",
|
|
151
|
-
children: [/* @__PURE__ */
|
|
152
|
+
children: [/* @__PURE__ */ T(b, {
|
|
152
153
|
size: 18,
|
|
153
154
|
className: "text-accent-teal"
|
|
154
155
|
}), " Certificates"]
|
|
155
|
-
}), /* @__PURE__ */
|
|
156
|
+
}), /* @__PURE__ */ T("div", {
|
|
156
157
|
className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3",
|
|
157
|
-
children:
|
|
158
|
-
className: t("group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg",
|
|
159
|
-
...
|
|
160
|
-
children: [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
158
|
+
children: M.map((e) => /* @__PURE__ */ E(O, {
|
|
159
|
+
className: t("group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg", F === e.id && "ring-2 ring-accent-primary"),
|
|
160
|
+
..._(F === e.id, () => I(F === e.id ? null : e.id)),
|
|
161
|
+
children: [
|
|
162
|
+
/* @__PURE__ */ E("div", {
|
|
163
|
+
className: "flex items-center gap-3",
|
|
164
|
+
children: [/* @__PURE__ */ T("span", {
|
|
165
|
+
className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-status-warning-bg-subtle text-status-warning-text ring-1 ring-status-warning-border/30 transition-transform duration-200 group-hover:scale-105",
|
|
166
|
+
children: /* @__PURE__ */ T(b, { size: 22 })
|
|
167
|
+
}), /* @__PURE__ */ E("div", {
|
|
168
|
+
className: "min-w-0",
|
|
169
|
+
children: [/* @__PURE__ */ T("button", {
|
|
170
|
+
type: "button",
|
|
171
|
+
"aria-pressed": F === e.id,
|
|
172
|
+
onClick: () => I(F === e.id ? null : e.id),
|
|
173
|
+
className: "block max-w-full truncate text-left text-sm font-medium text-text-primary",
|
|
174
|
+
children: e.course?.title ?? "Certificate"
|
|
175
|
+
}), /* @__PURE__ */ E("div", {
|
|
176
|
+
className: "truncate text-xs text-text-muted",
|
|
177
|
+
children: ["Serial ", e.serial]
|
|
178
|
+
})]
|
|
176
179
|
})]
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
180
|
+
}),
|
|
181
|
+
/* @__PURE__ */ E("div", {
|
|
182
|
+
className: "mt-auto flex items-center justify-between border-t border-border-seam pt-3 text-xs text-text-muted",
|
|
183
|
+
children: [/* @__PURE__ */ E("span", { children: [
|
|
184
|
+
"Score ",
|
|
185
|
+
/* @__PURE__ */ T("span", {
|
|
186
|
+
className: "font-semibold text-text-primary",
|
|
187
|
+
children: e.score
|
|
188
|
+
}),
|
|
189
|
+
" ·",
|
|
190
|
+
" ",
|
|
191
|
+
o(e.issuedAt)
|
|
192
|
+
] }), e.status === "ISSUED" ? /* @__PURE__ */ E("span", {
|
|
193
|
+
className: "inline-flex items-center gap-1 rounded-full border border-status-success-border bg-status-success-bg-subtle px-2 py-0.5 font-medium text-status-success-text",
|
|
194
|
+
children: [/* @__PURE__ */ T(w, { size: 12 }), " Verified"]
|
|
195
|
+
}) : /* @__PURE__ */ T(c, { status: e.status })]
|
|
196
|
+
}),
|
|
197
|
+
/* @__PURE__ */ T(m, {
|
|
198
|
+
serial: e.serial,
|
|
199
|
+
compact: !0
|
|
200
|
+
})
|
|
201
|
+
]
|
|
194
202
|
}, e.id))
|
|
195
203
|
})]
|
|
196
204
|
}) : null
|
|
@@ -198,6 +206,6 @@ function j() {
|
|
|
198
206
|
});
|
|
199
207
|
}
|
|
200
208
|
//#endregion
|
|
201
|
-
export {
|
|
209
|
+
export { M as CoursesMyLearningPagePage };
|
|
202
210
|
|
|
203
211
|
//# sourceMappingURL=CoursesMyLearningPagePage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CoursesMyLearningPagePage.js","names":[],"sources":["../../../src/technospam/pages/CoursesMyLearningPagePage.tsx"],"sourcesContent":["import { createElement } from 'react';\nimport { Award, BookOpen, GraduationCap, PlayCircle, ShieldCheck } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n EmptyView,\n ErrorView,\n LoadingRows,\n PageLayout,\n StatCard,\n StatusBadge,\n coverGradient,\n topicIcon,\n} from '../components';\nimport { useTechnoSpamNavigate } from '../providers/TechnoSpamProvider';\nimport {\n useMyCertificatesQuery,\n useMyEnrollmentsQuery,\n type TecEnrollment,\n} from '../api/technospamApi';\nimport { formatDate, relativeTime } from '../utils/format';\nimport { cn } from '../utils/cn';\nimport {\n FOCUSED_CARD_CLASS,\n focusableCardProps,\n useFocusedRecordParam,\n} from '../hooks/useFocusedRecordParam';\nimport { FOCUS_PARAMS } from '../utils/pageContext';\nimport { resumePath } from '../utils/lessonPath';\n\nfunction EnrollmentRow({\n e,\n onOpen,\n focused,\n onToggle,\n}: {\n e: TecEnrollment;\n onOpen: () => void;\n /** The focused enrollment (`?enrollmentId=`) is the one the assistant acts on. */\n focused: boolean;\n onToggle: () => void;\n}) {\n const seed = `${e.course?.topic ?? ''} ${e.course?.title ?? 'Course'}`;\n return (\n <GlassCard\n className={cn(\n 'group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:flex-row sm:items-center sm:gap-4',\n focused && FOCUSED_CARD_CLASS\n )}\n {...focusableCardProps(focused, onToggle)}\n >\n <span\n className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-sm transition-transform duration-200 group-hover:scale-105',\n coverGradient(seed)\n )}\n >\n {createElement(topicIcon(seed), { size: 20 })}\n </span>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <button\n type=\"button\"\n aria-pressed={focused}\n onClick={onToggle}\n className=\"truncate text-left text-sm font-semibold text-text-primary group-hover:text-accent-primary\"\n >\n {e.course?.title ?? 'Course'}\n </button>\n <StatusBadge status={e.status} />\n </div>\n <div className=\"mt-1 flex flex-wrap items-center gap-2 text-xs text-text-muted\">\n <span>{e.lessonsCompleted} lessons done</span>\n <span aria-hidden>·</span>\n <span>Active {relativeTime(e.lastActiveAt ?? e.startedAt)}</span>\n </div>\n <div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-full rounded-full bg-gradient-to-r from-accent-primary via-accent-indigo to-accent-purple transition-all duration-500\"\n style={{ width: `${e.progressPct}%` }}\n />\n </div>\n </div>\n <div className=\"flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:justify-center\">\n <div className=\"text-lg font-semibold tabular-nums text-text-primary\">{e.progressPct}%</div>\n <Button variant=\"ghost\" size=\"sm\" onClick={onOpen} data-testid={`resume-${e.courseId}`}>\n <PlayCircle size={15} /> Resume\n </Button>\n </div>\n </GlassCard>\n );\n}\n\n// Stable sibling lists: focusing an enrollment clears a focused certificate and vice versa.\nconst CERTIFICATE_SIBLING = [FOCUS_PARAMS.certificate] as const;\nconst ENROLLMENT_SIBLING = [FOCUS_PARAMS.enrollment] as const;\n\nexport function CoursesMyLearningPagePage() {\n const navigate = useTechnoSpamNavigate();\n\n // BOFF-10481: the learner's own enrollments and certificates come from the\n // canonical, self-scoped Buildmyiq fields. The previous cross-learner\n // `tecListEnrollments` / list-all reads needed an instructor permission a\n // plain learner does not hold. Each enrollment carries its next lesson, so\n // \"Resume\" (BOFF-4107) deep-links into the next incomplete lesson.\n const { enrollments, loading, error } = useMyEnrollmentsQuery();\n const { certificates } = useMyCertificatesQuery();\n // The focused enrollment / certificate live in the URL so the assistant's\n // page context names them (\"how far am I in this course?\").\n const [focusedEnrollmentId, setFocusedEnrollmentId] = useFocusedRecordParam(\n FOCUS_PARAMS.enrollment,\n CERTIFICATE_SIBLING\n );\n const [focusedCertificateId, setFocusedCertificateId] = useFocusedRecordParam(\n FOCUS_PARAMS.certificate,\n ENROLLMENT_SIBLING\n );\n\n const completed = enrollments.filter((e) => e.status === 'COMPLETED').length;\n const inProgress = enrollments.filter(\n (e) => e.status === 'IN_PROGRESS' || e.status === 'ENROLLED'\n ).length;\n\n return (\n <PageLayout\n hero\n title=\"My Learning\"\n description=\"Track your enrolled courses, progress, and earned certificates.\"\n actions={\n <Button variant=\"outline\" onClick={() => navigate('/courses/courses-page')}>\n <BookOpen size={16} /> Browse catalog\n </Button>\n }\n >\n <div className=\"mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4\">\n <StatCard\n label=\"Enrolled\"\n value={enrollments.length}\n hint=\"Courses you've enrolled in\"\n icon={BookOpen}\n tone=\"blue\"\n />\n <StatCard\n label=\"In progress\"\n value={inProgress}\n hint=\"Courses in progress\"\n icon={PlayCircle}\n tone=\"indigo\"\n />\n <StatCard\n label=\"Completed\"\n value={completed}\n hint=\"Courses completed\"\n icon={GraduationCap}\n tone=\"green\"\n />\n <StatCard\n label=\"Certificates\"\n value={certificates.length}\n hint=\"Certificates earned\"\n icon={Award}\n tone=\"purple\"\n />\n </div>\n\n {loading && !enrollments.length ? (\n <LoadingRows count={3} height=\"h-24\" />\n ) : error ? (\n <ErrorView title=\"Couldn't load your learning\" error={error} />\n ) : enrollments.length === 0 ? (\n <EmptyView\n icon={<BookOpen size={26} />}\n title=\"You haven't enrolled yet\"\n description=\"Browse the catalog and enroll in a course to start building skill by doing.\"\n action={<Button onClick={() => navigate('/courses/courses-page')}>Browse courses</Button>}\n />\n ) : (\n <div className=\"space-y-3\">\n {enrollments.map((e) => (\n <EnrollmentRow\n key={e.id}\n e={e}\n onOpen={() => navigate(resumePath(e.courseId, e.nextLessonId))}\n focused={focusedEnrollmentId === e.id}\n onToggle={() => setFocusedEnrollmentId(focusedEnrollmentId === e.id ? null : e.id)}\n />\n ))}\n </div>\n )}\n\n {certificates.length > 0 ? (\n <div className=\"mt-8\">\n <h2 className=\"mb-3 flex items-center gap-2 text-base font-semibold text-text-primary\">\n <Award size={18} className=\"text-accent-teal\" /> Certificates\n </h2>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n {certificates.map((c) => (\n <GlassCard\n key={c.id}\n className={cn(\n 'group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg',\n focusedCertificateId === c.id && FOCUSED_CARD_CLASS\n )}\n {...focusableCardProps(focusedCertificateId === c.id, () =>\n setFocusedCertificateId(focusedCertificateId === c.id ? null : c.id)\n )}\n >\n <div className=\"flex items-center gap-3\">\n <span className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-status-warning-bg-subtle text-status-warning-text ring-1 ring-status-warning-border/30 transition-transform duration-200 group-hover:scale-105\">\n <Award size={22} />\n </span>\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n aria-pressed={focusedCertificateId === c.id}\n onClick={() =>\n setFocusedCertificateId(focusedCertificateId === c.id ? null : c.id)\n }\n className=\"block max-w-full truncate text-left text-sm font-medium text-text-primary\"\n >\n {c.course?.title ?? 'Certificate'}\n </button>\n <div className=\"truncate text-xs text-text-muted\">Serial {c.serial}</div>\n </div>\n </div>\n <div className=\"mt-auto flex items-center justify-between border-t border-border-seam pt-3 text-xs text-text-muted\">\n <span>\n Score <span className=\"font-semibold text-text-primary\">{c.score}</span> ·{' '}\n {formatDate(c.issuedAt)}\n </span>\n {c.status === 'ISSUED' ? (\n <span className=\"inline-flex items-center gap-1 rounded-full border border-status-success-border bg-status-success-bg-subtle px-2 py-0.5 font-medium text-status-success-text\">\n <ShieldCheck size={12} /> Verified\n </span>\n ) : (\n <StatusBadge status={c.status} />\n )}\n </div>\n </GlassCard>\n ))}\n </div>\n </div>\n ) : null}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CoursesMyLearningPagePage.js","names":[],"sources":["../../../src/technospam/pages/CoursesMyLearningPagePage.tsx"],"sourcesContent":["import { createElement } from 'react';\nimport { Award, BookOpen, GraduationCap, PlayCircle, ShieldCheck } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport {\n CertificateVerifyLink,\n EmptyView,\n ErrorView,\n LoadingRows,\n PageLayout,\n StatCard,\n StatusBadge,\n coverGradient,\n topicIcon,\n} from '../components';\nimport { useTechnoSpamNavigate } from '../providers/TechnoSpamProvider';\nimport {\n useMyCertificatesQuery,\n useMyEnrollmentsQuery,\n type TecEnrollment,\n} from '../api/technospamApi';\nimport { formatDate, relativeTime } from '../utils/format';\nimport { cn } from '../utils/cn';\nimport {\n FOCUSED_CARD_CLASS,\n focusableCardProps,\n useFocusedRecordParam,\n} from '../hooks/useFocusedRecordParam';\nimport { FOCUS_PARAMS } from '../utils/pageContext';\nimport { resumePath } from '../utils/lessonPath';\n\nfunction EnrollmentRow({\n e,\n onOpen,\n focused,\n onToggle,\n}: {\n e: TecEnrollment;\n onOpen: () => void;\n /** The focused enrollment (`?enrollmentId=`) is the one the assistant acts on. */\n focused: boolean;\n onToggle: () => void;\n}) {\n const seed = `${e.course?.topic ?? ''} ${e.course?.title ?? 'Course'}`;\n return (\n <GlassCard\n className={cn(\n 'group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:flex-row sm:items-center sm:gap-4',\n focused && FOCUSED_CARD_CLASS\n )}\n {...focusableCardProps(focused, onToggle)}\n >\n <span\n className={cn(\n 'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-sm transition-transform duration-200 group-hover:scale-105',\n coverGradient(seed)\n )}\n >\n {createElement(topicIcon(seed), { size: 20 })}\n </span>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <button\n type=\"button\"\n aria-pressed={focused}\n onClick={onToggle}\n className=\"truncate text-left text-sm font-semibold text-text-primary group-hover:text-accent-primary\"\n >\n {e.course?.title ?? 'Course'}\n </button>\n <StatusBadge status={e.status} />\n </div>\n <div className=\"mt-1 flex flex-wrap items-center gap-2 text-xs text-text-muted\">\n <span>{e.lessonsCompleted} lessons done</span>\n <span aria-hidden>·</span>\n <span>Active {relativeTime(e.lastActiveAt ?? e.startedAt)}</span>\n </div>\n <div className=\"mt-2 h-1.5 w-full overflow-hidden rounded-full bg-bg-sunken\">\n <div\n className=\"h-full rounded-full bg-gradient-to-r from-accent-primary via-accent-indigo to-accent-purple transition-all duration-500\"\n style={{ width: `${e.progressPct}%` }}\n />\n </div>\n </div>\n <div className=\"flex items-center justify-between gap-3 sm:flex-col sm:items-end sm:justify-center\">\n <div className=\"text-lg font-semibold tabular-nums text-text-primary\">{e.progressPct}%</div>\n <Button variant=\"ghost\" size=\"sm\" onClick={onOpen} data-testid={`resume-${e.courseId}`}>\n <PlayCircle size={15} /> Resume\n </Button>\n </div>\n </GlassCard>\n );\n}\n\n// Stable sibling lists: focusing an enrollment clears a focused certificate and vice versa.\nconst CERTIFICATE_SIBLING = [FOCUS_PARAMS.certificate] as const;\nconst ENROLLMENT_SIBLING = [FOCUS_PARAMS.enrollment] as const;\n\nexport function CoursesMyLearningPagePage() {\n const navigate = useTechnoSpamNavigate();\n\n // BOFF-10481: the learner's own enrollments and certificates come from the\n // canonical, self-scoped Buildmyiq fields. The previous cross-learner\n // `tecListEnrollments` / list-all reads needed an instructor permission a\n // plain learner does not hold. Each enrollment carries its next lesson, so\n // \"Resume\" (BOFF-4107) deep-links into the next incomplete lesson.\n const { enrollments, loading, error } = useMyEnrollmentsQuery();\n const { certificates } = useMyCertificatesQuery();\n // The focused enrollment / certificate live in the URL so the assistant's\n // page context names them (\"how far am I in this course?\").\n const [focusedEnrollmentId, setFocusedEnrollmentId] = useFocusedRecordParam(\n FOCUS_PARAMS.enrollment,\n CERTIFICATE_SIBLING\n );\n const [focusedCertificateId, setFocusedCertificateId] = useFocusedRecordParam(\n FOCUS_PARAMS.certificate,\n ENROLLMENT_SIBLING\n );\n\n const completed = enrollments.filter((e) => e.status === 'COMPLETED').length;\n const inProgress = enrollments.filter(\n (e) => e.status === 'IN_PROGRESS' || e.status === 'ENROLLED'\n ).length;\n\n return (\n <PageLayout\n hero\n title=\"My Learning\"\n description=\"Track your enrolled courses, progress, and earned certificates.\"\n actions={\n <Button variant=\"outline\" onClick={() => navigate('/courses/courses-page')}>\n <BookOpen size={16} /> Browse catalog\n </Button>\n }\n >\n <div className=\"mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4\">\n <StatCard\n label=\"Enrolled\"\n value={enrollments.length}\n hint=\"Courses you've enrolled in\"\n icon={BookOpen}\n tone=\"blue\"\n />\n <StatCard\n label=\"In progress\"\n value={inProgress}\n hint=\"Courses in progress\"\n icon={PlayCircle}\n tone=\"indigo\"\n />\n <StatCard\n label=\"Completed\"\n value={completed}\n hint=\"Courses completed\"\n icon={GraduationCap}\n tone=\"green\"\n />\n <StatCard\n label=\"Certificates\"\n value={certificates.length}\n hint=\"Certificates earned\"\n icon={Award}\n tone=\"purple\"\n />\n </div>\n\n {loading && !enrollments.length ? (\n <LoadingRows count={3} height=\"h-24\" />\n ) : error ? (\n <ErrorView title=\"Couldn't load your learning\" error={error} />\n ) : enrollments.length === 0 ? (\n <EmptyView\n icon={<BookOpen size={26} />}\n title=\"You haven't enrolled yet\"\n description=\"Browse the catalog and enroll in a course to start building skill by doing.\"\n action={<Button onClick={() => navigate('/courses/courses-page')}>Browse courses</Button>}\n />\n ) : (\n <div className=\"space-y-3\">\n {enrollments.map((e) => (\n <EnrollmentRow\n key={e.id}\n e={e}\n onOpen={() => navigate(resumePath(e.courseId, e.nextLessonId))}\n focused={focusedEnrollmentId === e.id}\n onToggle={() => setFocusedEnrollmentId(focusedEnrollmentId === e.id ? null : e.id)}\n />\n ))}\n </div>\n )}\n\n {certificates.length > 0 ? (\n <div className=\"mt-8\">\n <h2 className=\"mb-3 flex items-center gap-2 text-base font-semibold text-text-primary\">\n <Award size={18} className=\"text-accent-teal\" /> Certificates\n </h2>\n <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n {certificates.map((c) => (\n <GlassCard\n key={c.id}\n className={cn(\n 'group flex cursor-pointer flex-col gap-3 p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg',\n focusedCertificateId === c.id && FOCUSED_CARD_CLASS\n )}\n {...focusableCardProps(focusedCertificateId === c.id, () =>\n setFocusedCertificateId(focusedCertificateId === c.id ? null : c.id)\n )}\n >\n <div className=\"flex items-center gap-3\">\n <span className=\"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-status-warning-bg-subtle text-status-warning-text ring-1 ring-status-warning-border/30 transition-transform duration-200 group-hover:scale-105\">\n <Award size={22} />\n </span>\n <div className=\"min-w-0\">\n <button\n type=\"button\"\n aria-pressed={focusedCertificateId === c.id}\n onClick={() =>\n setFocusedCertificateId(focusedCertificateId === c.id ? null : c.id)\n }\n className=\"block max-w-full truncate text-left text-sm font-medium text-text-primary\"\n >\n {c.course?.title ?? 'Certificate'}\n </button>\n <div className=\"truncate text-xs text-text-muted\">Serial {c.serial}</div>\n </div>\n </div>\n <div className=\"mt-auto flex items-center justify-between border-t border-border-seam pt-3 text-xs text-text-muted\">\n <span>\n Score <span className=\"font-semibold text-text-primary\">{c.score}</span> ·{' '}\n {formatDate(c.issuedAt)}\n </span>\n {c.status === 'ISSUED' ? (\n <span className=\"inline-flex items-center gap-1 rounded-full border border-status-success-border bg-status-success-bg-subtle px-2 py-0.5 font-medium text-status-success-text\">\n <ShieldCheck size={12} /> Verified\n </span>\n ) : (\n <StatusBadge status={c.status} />\n )}\n </div>\n <CertificateVerifyLink serial={c.serial} compact />\n </GlassCard>\n ))}\n </div>\n </div>\n ) : null}\n </PageLayout>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CertificateVerifyPage.d.ts","sourceRoot":"","sources":["../../../../src/technospam/pages/public/CertificateVerifyPage.tsx"],"names":[],"mappings":"AA2IA,wBAAgB,qBAAqB,CAAC,EAAE,aAAa,EAAE,GAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAO,+BAiBvF"}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { formatDate as e, formatDateTime as t, humanizeEnum as n } from "../../utils/format.js";
|
|
2
|
+
import { certificateVerifyPath as r, fetchCertificateVerification as i, isPlausibleSerial as a, normalizeSerial as o } from "../../lib/certificateVerify.js";
|
|
3
|
+
import { useState as s } from "react";
|
|
4
|
+
import { Award as c, ShieldAlert as l, ShieldCheck as u, ShieldQuestion as d } from "lucide-react";
|
|
5
|
+
import { useNavigate as f, useParams as p } from "react-router";
|
|
6
|
+
import { QueryClient as m, QueryClientProvider as h, useQuery as g } from "@tanstack/react-query";
|
|
7
|
+
import { Fragment as _, jsx as v, jsxs as y } from "react/jsx-runtime";
|
|
8
|
+
import { Button as b, GlassCard as x, Input as S, Label as C, Skeleton as w } from "@burdenoff/fe-libs/ui";
|
|
9
|
+
//#region src/technospam/pages/public/CertificateVerifyPage.tsx
|
|
10
|
+
function T({ verification: t }) {
|
|
11
|
+
return t.status === "VALID" ? /* @__PURE__ */ y("div", {
|
|
12
|
+
className: "flex items-start gap-3 rounded-xl border border-status-success-border bg-status-success-bg-subtle p-4 text-status-success-text",
|
|
13
|
+
"data-testid": "tec-verify-status",
|
|
14
|
+
"data-status": "VALID",
|
|
15
|
+
children: [/* @__PURE__ */ v(u, {
|
|
16
|
+
size: 22,
|
|
17
|
+
className: "mt-0.5 shrink-0"
|
|
18
|
+
}), /* @__PURE__ */ y("div", { children: [/* @__PURE__ */ v("div", {
|
|
19
|
+
className: "text-base font-semibold",
|
|
20
|
+
children: "Valid certificate"
|
|
21
|
+
}), /* @__PURE__ */ y("p", {
|
|
22
|
+
className: "text-sm",
|
|
23
|
+
children: [
|
|
24
|
+
"This serial belongs to a certificate issued by ",
|
|
25
|
+
t.issuer,
|
|
26
|
+
" that has not been revoked."
|
|
27
|
+
]
|
|
28
|
+
})] })]
|
|
29
|
+
}) : t.status === "REVOKED" ? /* @__PURE__ */ y("div", {
|
|
30
|
+
className: "flex items-start gap-3 rounded-xl border border-status-error-border bg-status-error-bg-subtle p-4 text-status-error-text",
|
|
31
|
+
"data-testid": "tec-verify-status",
|
|
32
|
+
"data-status": "REVOKED",
|
|
33
|
+
children: [/* @__PURE__ */ v(l, {
|
|
34
|
+
size: 22,
|
|
35
|
+
className: "mt-0.5 shrink-0"
|
|
36
|
+
}), /* @__PURE__ */ y("div", { children: [/* @__PURE__ */ v("div", {
|
|
37
|
+
className: "text-base font-semibold",
|
|
38
|
+
children: "Revoked certificate"
|
|
39
|
+
}), /* @__PURE__ */ y("p", {
|
|
40
|
+
className: "text-sm",
|
|
41
|
+
children: [
|
|
42
|
+
"This certificate was issued by ",
|
|
43
|
+
t.issuer,
|
|
44
|
+
" but has since been revoked",
|
|
45
|
+
t.revokedAt ? ` (on ${e(t.revokedAt)})` : "",
|
|
46
|
+
". It should not be relied on."
|
|
47
|
+
]
|
|
48
|
+
})] })]
|
|
49
|
+
}) : /* @__PURE__ */ y("div", {
|
|
50
|
+
className: "flex items-start gap-3 rounded-xl border border-status-warning-border bg-status-warning-bg-subtle p-4 text-status-warning-text",
|
|
51
|
+
"data-testid": "tec-verify-status",
|
|
52
|
+
"data-status": "NOT_FOUND",
|
|
53
|
+
children: [/* @__PURE__ */ v(d, {
|
|
54
|
+
size: 22,
|
|
55
|
+
className: "mt-0.5 shrink-0"
|
|
56
|
+
}), /* @__PURE__ */ y("div", { children: [/* @__PURE__ */ v("div", {
|
|
57
|
+
className: "text-base font-semibold",
|
|
58
|
+
children: "No certificate found"
|
|
59
|
+
}), /* @__PURE__ */ y("p", {
|
|
60
|
+
className: "text-sm",
|
|
61
|
+
children: [
|
|
62
|
+
"No ",
|
|
63
|
+
t.issuer,
|
|
64
|
+
" certificate carries this serial. Check that it was copied exactly as printed on the certificate."
|
|
65
|
+
]
|
|
66
|
+
})] })]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function E({ label: e, value: t, testId: n }) {
|
|
70
|
+
return /* @__PURE__ */ y("div", {
|
|
71
|
+
className: "min-w-0",
|
|
72
|
+
children: [/* @__PURE__ */ v("dt", {
|
|
73
|
+
className: "text-[11px] font-medium uppercase tracking-wide text-text-muted",
|
|
74
|
+
children: e
|
|
75
|
+
}), /* @__PURE__ */ v("dd", {
|
|
76
|
+
className: "truncate text-sm text-text-primary",
|
|
77
|
+
"data-testid": n,
|
|
78
|
+
children: t
|
|
79
|
+
})]
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function D({ verification: r }) {
|
|
83
|
+
let i = r.status !== "NOT_FOUND";
|
|
84
|
+
return /* @__PURE__ */ y("div", {
|
|
85
|
+
className: "space-y-4",
|
|
86
|
+
"data-testid": "tec-verify-result",
|
|
87
|
+
children: [
|
|
88
|
+
/* @__PURE__ */ v(T, { verification: r }),
|
|
89
|
+
/* @__PURE__ */ y("dl", {
|
|
90
|
+
className: "grid grid-cols-1 gap-4 sm:grid-cols-2",
|
|
91
|
+
children: [
|
|
92
|
+
/* @__PURE__ */ v(E, {
|
|
93
|
+
label: "Serial number",
|
|
94
|
+
value: r.serial,
|
|
95
|
+
testId: "tec-verify-serial"
|
|
96
|
+
}),
|
|
97
|
+
i ? /* @__PURE__ */ y(_, { children: [
|
|
98
|
+
/* @__PURE__ */ v(E, {
|
|
99
|
+
label: "Course",
|
|
100
|
+
value: r.courseTitle ?? "—",
|
|
101
|
+
testId: "tec-verify-course"
|
|
102
|
+
}),
|
|
103
|
+
/* @__PURE__ */ v(E, {
|
|
104
|
+
label: "Level",
|
|
105
|
+
value: r.courseLevel ? n(r.courseLevel) : "—",
|
|
106
|
+
testId: "tec-verify-level"
|
|
107
|
+
}),
|
|
108
|
+
/* @__PURE__ */ v(E, {
|
|
109
|
+
label: "Final score",
|
|
110
|
+
value: r.score === null ? "—" : `${r.score} / 100`,
|
|
111
|
+
testId: "tec-verify-score"
|
|
112
|
+
}),
|
|
113
|
+
/* @__PURE__ */ v(E, {
|
|
114
|
+
label: "Issued on",
|
|
115
|
+
value: r.issuedAt ? e(r.issuedAt) : "—",
|
|
116
|
+
testId: "tec-verify-issued"
|
|
117
|
+
})
|
|
118
|
+
] }) : null,
|
|
119
|
+
/* @__PURE__ */ v(E, {
|
|
120
|
+
label: "Checked at",
|
|
121
|
+
value: t(r.verifiedAt),
|
|
122
|
+
testId: "tec-verify-checked"
|
|
123
|
+
})
|
|
124
|
+
]
|
|
125
|
+
}),
|
|
126
|
+
/* @__PURE__ */ v("p", {
|
|
127
|
+
className: "text-xs text-text-muted",
|
|
128
|
+
children: "For privacy, verification confirms the course, score, dates and status only. It never shows the learner's name or contact details; compare them against the certificate the candidate shared with you."
|
|
129
|
+
})
|
|
130
|
+
]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function O({ apiGatewayUrl: e } = {}) {
|
|
134
|
+
let { serial: t } = p(), n = o(t), [r] = s(() => new m({ defaultOptions: { queries: {
|
|
135
|
+
retry: !1,
|
|
136
|
+
refetchOnWindowFocus: !1
|
|
137
|
+
} } }));
|
|
138
|
+
return /* @__PURE__ */ v(h, {
|
|
139
|
+
client: r,
|
|
140
|
+
children: /* @__PURE__ */ v(k, {
|
|
141
|
+
serial: n,
|
|
142
|
+
apiGatewayUrl: e
|
|
143
|
+
}, n)
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function k({ serial: e, apiGatewayUrl: t }) {
|
|
147
|
+
let n = f(), [l, u] = s(e), d = g({
|
|
148
|
+
queryKey: [
|
|
149
|
+
"tecVerifyCertificate",
|
|
150
|
+
t ?? "",
|
|
151
|
+
e
|
|
152
|
+
],
|
|
153
|
+
queryFn: () => i(t, e),
|
|
154
|
+
enabled: e.length > 0,
|
|
155
|
+
staleTime: 0,
|
|
156
|
+
gcTime: 0
|
|
157
|
+
}), p = d.data, m = e.length > 0 && (d.isPending || d.isFetching), h = d.isError || p?.kind === "error", _ = o(l), T = a(_);
|
|
158
|
+
return /* @__PURE__ */ y("main", {
|
|
159
|
+
className: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-4 py-10 sm:py-16",
|
|
160
|
+
children: [/* @__PURE__ */ y("header", {
|
|
161
|
+
className: "space-y-2 text-center",
|
|
162
|
+
children: [
|
|
163
|
+
/* @__PURE__ */ y("div", {
|
|
164
|
+
className: "inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-text-muted",
|
|
165
|
+
children: [/* @__PURE__ */ v(c, {
|
|
166
|
+
size: 14,
|
|
167
|
+
className: "text-status-warning-text"
|
|
168
|
+
}), " TechnoSpam"]
|
|
169
|
+
}),
|
|
170
|
+
/* @__PURE__ */ v("h1", {
|
|
171
|
+
className: "text-2xl font-bold text-text-primary sm:text-3xl",
|
|
172
|
+
children: "Verify a certificate"
|
|
173
|
+
}),
|
|
174
|
+
/* @__PURE__ */ v("p", {
|
|
175
|
+
className: "text-sm text-text-secondary",
|
|
176
|
+
children: "Confirm that a TechnoSpam certificate is genuine and still valid. No account is needed."
|
|
177
|
+
})
|
|
178
|
+
]
|
|
179
|
+
}), /* @__PURE__ */ y(x, {
|
|
180
|
+
className: "space-y-6 p-5 sm:p-6",
|
|
181
|
+
children: [
|
|
182
|
+
/* @__PURE__ */ y("form", {
|
|
183
|
+
className: "flex flex-col gap-2",
|
|
184
|
+
onSubmit: (t) => {
|
|
185
|
+
if (t.preventDefault(), T) {
|
|
186
|
+
if (_ === e) {
|
|
187
|
+
d.refetch();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
n(r(_));
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
role: "search",
|
|
194
|
+
children: [
|
|
195
|
+
/* @__PURE__ */ v(C, {
|
|
196
|
+
htmlFor: "tec-verify-input",
|
|
197
|
+
children: "Certificate serial number"
|
|
198
|
+
}),
|
|
199
|
+
/* @__PURE__ */ y("div", {
|
|
200
|
+
className: "flex flex-col gap-2 sm:flex-row",
|
|
201
|
+
children: [/* @__PURE__ */ v(S, {
|
|
202
|
+
id: "tec-verify-input",
|
|
203
|
+
"data-testid": "tec-verify-input",
|
|
204
|
+
value: l,
|
|
205
|
+
onChange: (e) => u(e.target.value),
|
|
206
|
+
placeholder: "TEC-XXXXXXXX-XXXXXXXX",
|
|
207
|
+
autoComplete: "off",
|
|
208
|
+
spellCheck: !1,
|
|
209
|
+
className: "font-mono"
|
|
210
|
+
}), /* @__PURE__ */ v(b, {
|
|
211
|
+
type: "submit",
|
|
212
|
+
disabled: !T,
|
|
213
|
+
"data-testid": "tec-verify-submit",
|
|
214
|
+
children: "Verify"
|
|
215
|
+
})]
|
|
216
|
+
}),
|
|
217
|
+
l.trim() && !T ? /* @__PURE__ */ v("p", {
|
|
218
|
+
className: "text-xs text-status-error-text",
|
|
219
|
+
children: "A serial uses only letters, digits, dots, underscores and hyphens."
|
|
220
|
+
}) : null
|
|
221
|
+
]
|
|
222
|
+
}),
|
|
223
|
+
m ? /* @__PURE__ */ y("div", {
|
|
224
|
+
className: "space-y-3",
|
|
225
|
+
"aria-busy": "true",
|
|
226
|
+
"aria-live": "polite",
|
|
227
|
+
children: [/* @__PURE__ */ v(w, { className: "h-16 w-full" }), /* @__PURE__ */ v(w, { className: "h-24 w-full" })]
|
|
228
|
+
}) : null,
|
|
229
|
+
!m && p?.kind === "result" ? /* @__PURE__ */ v(D, { verification: p.verification }) : null,
|
|
230
|
+
!m && p?.kind === "invalid" ? /* @__PURE__ */ v("p", {
|
|
231
|
+
className: "text-sm text-status-error-text",
|
|
232
|
+
"data-testid": "tec-verify-invalid",
|
|
233
|
+
children: "That does not look like a certificate serial. Check it against the certificate."
|
|
234
|
+
}) : null,
|
|
235
|
+
!m && h ? /* @__PURE__ */ y("div", {
|
|
236
|
+
className: "flex flex-col items-start gap-2",
|
|
237
|
+
"data-testid": "tec-verify-error",
|
|
238
|
+
children: [/* @__PURE__ */ v("p", {
|
|
239
|
+
className: "text-sm text-status-error-text",
|
|
240
|
+
children: "The verification service could not be reached. Please try again."
|
|
241
|
+
}), /* @__PURE__ */ v(b, {
|
|
242
|
+
size: "sm",
|
|
243
|
+
variant: "outline",
|
|
244
|
+
onClick: () => {
|
|
245
|
+
d.refetch();
|
|
246
|
+
},
|
|
247
|
+
children: "Try again"
|
|
248
|
+
})]
|
|
249
|
+
}) : null
|
|
250
|
+
]
|
|
251
|
+
})]
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { O as CertificateVerifyPage };
|
|
256
|
+
|
|
257
|
+
//# sourceMappingURL=CertificateVerifyPage.js.map
|