Compare commits

...

2 Commits

Author SHA1 Message Date
1014f4a72f feat(admin): notification details, question type library, and schema UX
Wire GET /notifications/:id for topbar and notifications page detail views, harden notification WebSocket lifecycle, paginate question type and app version lists from API, and expand dynamic question type schema labels and slot editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 05:44:09 -07:00
92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00
72 changed files with 2703 additions and 1041 deletions

View File

@ -1,4 +1,5 @@
import http from "./http"
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
import type {
AppVersion,
AppVersionMutationResponse,
@ -77,7 +78,7 @@ export type GetAppVersionsParams = {
}
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
const limit = params.limit ?? 20
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
const offset = params.offset ?? 0
return http
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })

View File

@ -1,6 +1,6 @@
import http from "./http"
export type UploadMediaType = "image" | "audio" | "video"
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse {
@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
})
: uploadMediaFile("video", fileOrUrl, options)
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", {
params: { key },

View File

@ -1,35 +1,125 @@
import http from "./http";
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
import http from "./http"
import type {
GetNotificationsResponse,
Notification,
UnreadCountResponse,
} from "../types/notification.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function unwrapEnvelopeData(body: unknown): unknown {
if (!isRecord(body)) return body
if ("data" in body || "Data" in body) {
return body.data ?? body.Data
}
return body
}
function normalizePayload(raw: unknown): Notification["payload"] {
if (!isRecord(raw)) {
return { tags: null }
}
const tags = Array.isArray(raw.tags)
? raw.tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0)
: null
return {
headline: raw.headline != null ? String(raw.headline) : undefined,
title: raw.title != null ? String(raw.title) : undefined,
message: raw.message != null ? String(raw.message) : undefined,
body: raw.body != null ? String(raw.body) : undefined,
tags,
}
}
export function normalizeNotification(raw: unknown): Notification | null {
if (!isRecord(raw)) return null
const id = String(raw.id ?? "")
if (!id) return null
return {
id,
recipient_id: Number(raw.recipient_id ?? 0),
receiver_type: raw.receiver_type != null ? String(raw.receiver_type) : undefined,
type: String(raw.type ?? ""),
level: String(raw.level ?? ""),
error_severity: String(raw.error_severity ?? ""),
reciever: String(raw.reciever ?? ""),
is_read: Boolean(raw.is_read),
delivery_status: String(raw.delivery_status ?? ""),
delivery_channel: String(raw.delivery_channel ?? ""),
payload: normalizePayload(raw.payload),
timestamp: String(raw.timestamp ?? ""),
expires: String(raw.expires ?? ""),
image: String(raw.image ?? ""),
}
}
function parseNotificationsListData(body: unknown, limit: number, offset: number): GetNotificationsResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) {
return { notifications: [], total_count: 0, limit, offset }
}
const rows = Array.isArray(inner.notifications) ? inner.notifications : []
const notifications = rows
.map(normalizeNotification)
.filter((n): n is Notification => n !== null)
return {
notifications,
total_count: Number(inner.total_count ?? notifications.length),
limit: Number(inner.limit ?? limit),
offset: Number(inner.offset ?? offset),
}
}
function parseUnreadCount(body: unknown): UnreadCountResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) return { unread: 0 }
return { unread: Number(inner.unread ?? 0) }
}
export const getNotifications = (limit = 10, offset = 0) =>
http.get<GetNotificationsResponse>("/notifications", {
params: { limit, offset },
});
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
...res,
data: parseNotificationsListData(res.data, limit, offset),
}))
export const getNotificationById = (id: string) =>
http.get<unknown>(`/notifications/${id}`).then((res) => ({
...res,
data: normalizeNotification(unwrapEnvelopeData(res.data)),
}))
export const getUnreadCount = () =>
http.get<UnreadCountResponse>("/notifications/unread");
http.get<unknown>("/notifications/unread").then((res) => ({
...res,
data: parseUnreadCount(res.data),
}))
export const markAsRead = (id: string) =>
http.patch(`/notifications/${id}/read`);
http.patch(`/notifications/${id}/read`)
export const markAsUnread = (id: string) =>
http.patch(`/notifications/${id}/unread`);
http.patch(`/notifications/${id}/unread`)
export const markAllRead = () =>
http.post("/notifications/mark-all-read");
http.post("/notifications/mark-all-read")
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread");
http.post("/notifications/mark-all-unread")
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
http.post("/notifications/bulk-sms", data);
http.post("/notifications/bulk-sms", data)
export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
})
export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
})

View File

@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
/** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = extractDefinitionMutationId
export interface QuestionTypeDefinitionsListParams {
include_system?: boolean
status?: string
limit?: number
offset?: number
}
export interface QuestionTypeDefinitionsListResult {
definitions: QuestionTypeDefinition[]
total_count?: number
}
function parseListTotalCount(body: unknown): number | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
const o = body as Record<string, unknown>
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
if (Number.isFinite(direct) && direct >= 0) return direct
const meta = o.metadata ?? o.Metadata
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
}
const data = o.data ?? o.Data
if (data && typeof data === "object" && !Array.isArray(data)) {
return parseListTotalCount(data)
}
return undefined
}
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return []
if (Array.isArray(payload)) {
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions
const inner =
o.question_type_definitions ??
o.QuestionTypeDefinitions ??
o.definitions ??
o.items ??
o.rows ??
o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object") return parseDefinitionsList(inner)
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") {
const single = normalizeTypeDefinitionFromApi(data)
return single ? [single] : []
}
if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(params?: {
include_system?: boolean
status?: string
limit?: number
offset?: number
}) {
export async function getQuestionTypeDefinitions(
params?: QuestionTypeDefinitionsListParams,
): Promise<QuestionTypeDefinitionsListResult> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
return parseDefinitionsList(raw)
const definitions = parseDefinitionsList(raw)
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
return total_count != null ? { definitions, total_count } : { definitions }
}
/**

View File

@ -6,15 +6,18 @@ import {
type ChangeEvent,
type DragEvent,
} from "react"
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicTableBuilder } from "./DynamicTableBuilder"
import { slotLabel } from "../../lib/schemaSlotLabel"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
@ -28,13 +31,43 @@ export interface DynamicSchemaSlotRow {
required?: boolean
}
function slotMediaMode(kind: string): "image" | "audio" | "text" {
function slotMediaMode(
kind: string,
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u.startsWith("AUDIO")) return "audio"
if (u === "TABLE") return "table"
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
return "text"
}
function readSecondsFieldValue(raw: string): string {
const t = raw.trim()
if (!t) return ""
if (/^\d+$/.test(t)) return t
try {
const parsed = JSON.parse(t) as unknown
if (typeof parsed === "number" && Number.isFinite(parsed)) return String(parsed)
if (parsed && typeof parsed === "object" && "seconds" in parsed) {
const seconds = (parsed as { seconds?: unknown }).seconds
if (typeof seconds === "number" && Number.isFinite(seconds)) return String(seconds)
}
} catch {
/* keep raw */
}
return t
}
function writeSecondsFieldValue(raw: string): string {
const t = raw.trim()
if (!t) return ""
const n = Number.parseInt(t, 10)
if (!Number.isFinite(n) || n < 0) return ""
return JSON.stringify({ seconds: n })
}
function isHttpUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim())
}
@ -137,7 +170,9 @@ function DynamicImageSlot({
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div
@ -407,7 +442,9 @@ function DynamicAudioSlot({
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
@ -537,6 +574,105 @@ export interface DynamicSchemaSlotFieldProps {
disabled?: boolean
}
function DynamicPdfSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (ext !== "pdf") {
toast.error("Only PDF files are allowed")
return
}
if (file.size > 25 * 1024 * 1024) {
toast.error("PDF is too large", { description: "Maximum size is 25 MB." })
return
}
setUploading(true)
try {
const res = await uploadPdfFile(file)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Upload did not return a URL")
onChange(url)
toast.success("PDF uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload PDF")
} finally {
setUploading(false)
}
},
[disabled, onChange, uploading],
)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
{uploading ? <SpinnerIcon className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
Upload PDF
</Button>
{value.trim() ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => onChange("")}
>
Clear
</Button>
) : null}
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://… or upload above"
className="h-11 rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
/>
</div>
)
}
export function DynamicSchemaSlotField({
row,
value,
@ -544,24 +680,52 @@ export function DynamicSchemaSlotField({
disabled = false,
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const baseLabel =
row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}`
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
if (mode === "table") {
return (
<DynamicTableBuilder
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
if (mode === "seconds") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
<Input
type="number"
min={0}
step={1}
value={readSecondsFieldValue(value)}
onChange={(e) => onChange(writeSecondsFieldValue(e.target.value))}
placeholder="e.g. 30"
className="h-11 max-w-[200px] rounded-lg border-grayScale-200"
disabled={disabled}
/>
<p className="text-[11px] text-grayScale-500">Stored as seconds (e.g. {`{"seconds": 30}`}).</p>
</div>
)
}
if (mode === "text") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
<Textarea
rows={3}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="URL, plain text, or JSON object"
placeholder={
row.kind === "OPTION"
? '{"options":[{"id":"a","text":"…","is_correct":true}]}'
: "URL, plain text, or JSON object"
}
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled}
/>
@ -575,8 +739,20 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
if (mode === "pdf") {
return (
<DynamicPdfSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
@ -586,8 +762,8 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}

View File

@ -0,0 +1,250 @@
import { useMemo } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { cn } from "../../lib/utils"
import {
createEmptyTable,
parseTableSlotValue,
serializeTableSlotValue,
type DynamicTableValue,
} from "../../lib/dynamicTableValue"
export type DynamicTableBuilderProps = {
value: string
onChange: (next: string) => void
disabled?: boolean
slotLabel: string
slotMeta: string
}
function normalizeTable(table: DynamicTableValue): DynamicTableValue {
const columns =
table.columns.length > 0
? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
: ["Column 1"]
const colCount = columns.length
const rows =
table.rows.length > 0
? table.rows.map((row) => {
const cells = [...row]
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
: [Array(colCount).fill("")]
return { columns, rows }
}
export function DynamicTableBuilder({
value,
onChange,
disabled = false,
slotLabel,
slotMeta,
}: DynamicTableBuilderProps) {
const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value])
const commit = (next: DynamicTableValue) => {
onChange(serializeTableSlotValue(normalizeTable(next)))
}
const updateColumn = (colIndex: number, text: string) => {
const columns = [...table.columns]
columns[colIndex] = text
commit({ columns, rows: table.rows })
}
const updateCell = (rowIndex: number, colIndex: number, text: string) => {
const rows = table.rows.map((r) => [...r])
rows[rowIndex][colIndex] = text
commit({ columns: table.columns, rows })
}
const addColumn = () => {
const columns = [...table.columns, `Column ${table.columns.length + 1}`]
const rows = table.rows.map((row) => [...row, ""])
commit({ columns, rows })
}
const removeColumn = (colIndex: number) => {
if (table.columns.length <= 1) return
const columns = table.columns.filter((_, i) => i !== colIndex)
const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex))
commit({ columns, rows })
}
const addRow = () => {
const rows = [...table.rows, Array(table.columns.length).fill("")]
commit({ columns: table.columns, rows })
}
const removeRow = (rowIndex: number) => {
if (table.rows.length <= 1) return
const rows = table.rows.filter((_, i) => i !== rowIndex)
commit({ columns: table.columns, rows })
}
const resetTable = () => {
commit(createEmptyTable(2, 1))
}
const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
const previewRows = table.rows.map((row) =>
row.map((cell, ci) => cell.trim() || ""),
)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<p className="text-xs text-grayScale-500">
Build the reference table learners will see with the question.
</p>
<div className="overflow-x-auto rounded-xl border border-grayScale-200 bg-white">
<table className="w-full min-w-[320px] border-collapse text-sm">
<thead>
<tr className="bg-grayScale-50/90">
{table.columns.map((col, colIndex) => (
<th
key={`col-${colIndex}`}
className="border-b border-r border-grayScale-200 p-1.5 align-top last:border-r-0"
>
<div className="flex min-w-[100px] items-start gap-1">
<Input
value={col}
disabled={disabled}
onChange={(e) => updateColumn(colIndex, e.target.value)}
placeholder={`Column ${colIndex + 1}`}
className="h-9 border-grayScale-200 bg-white text-xs font-semibold"
/>
{table.columns.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="h-9 w-9 shrink-0 p-0 text-grayScale-400 hover:text-red-600"
aria-label={`Remove column ${colIndex + 1}`}
onClick={() => removeColumn(colIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</th>
))}
<th className="w-10 border-b border-grayScale-200 bg-grayScale-50/90 p-1" />
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="group">
{row.map((cell, colIndex) => (
<td
key={`cell-${rowIndex}-${colIndex}`}
className="border-b border-r border-grayScale-100 p-1.5 last:border-r-0"
>
<Input
value={cell}
disabled={disabled}
onChange={(e) => updateCell(rowIndex, colIndex, e.target.value)}
placeholder="Cell value"
className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm"
/>
</td>
))}
<td className="border-b border-grayScale-100 p-1 align-middle">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || table.rows.length <= 1}
className="h-9 w-9 p-0 text-grayScale-400 hover:text-red-600 disabled:opacity-30"
aria-label={`Remove row ${rowIndex + 1}`}
onClick={() => removeRow(rowIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addColumn}
>
<Plus className="h-3.5 w-3.5" />
Add column
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addRow}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={resetTable}
>
Reset table
</Button>
</div>
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/60 p-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Learner preview
</p>
<div className="overflow-x-auto rounded-lg border border-grayScale-200 bg-white">
<table className={cn("w-full min-w-[240px] border-collapse text-sm text-grayScale-800")}>
<thead>
<tr className="bg-brand-50/80">
{previewColumns.map((col, i) => (
<th
key={`preview-h-${i}`}
className="border border-grayScale-200 px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-grayScale-700"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={`preview-r-${ri}`} className={ri % 2 === 0 ? "bg-white" : "bg-grayScale-50/50"}>
{row.map((cell, ci) => (
<td
key={`preview-c-${ri}-${ci}`}
className="border border-grayScale-100 px-3 py-2 text-sm"
>
{cell || <span className="text-grayScale-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
setDefinitionsLoading(true)
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
} finally {
@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
{value.questionType === "DYNAMIC" && (
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
slots: text or JSON.
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
fields accept text or structured values where noted.
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">

View File

@ -0,0 +1,169 @@
import { Badge } from "../ui/badge"
import { Button } from "../ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { SpinnerIcon } from "../ui/spinner-icon"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationDateTime,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
isMeaningfulExpiry,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
type NotificationDetailDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
notification: Notification | null
loading?: boolean
error?: boolean
onRetry?: () => void
}
export function NotificationDetailDialog({
open,
onOpenChange,
notification,
loading = false,
error = false,
onRetry,
}: NotificationDetailDialogProps) {
const config = notification
? NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
: DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading notification</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<p className="text-sm font-medium text-grayScale-700">Could not load notification</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Try again
</Button>
) : null}
</div>
) : notification ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg ${config.bg} ${config.color}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-base">
{getNotificationTitle(notification) || "Notification"}
</span>
</DialogTitle>
<DialogDescription>
Sent via {notification.delivery_channel || "in-app"} ·{" "}
{formatNotificationTimestamp(notification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{notification.image ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-grayScale-50">
<img
src={notification.image}
alt=""
className="max-h-48 w-full object-cover"
/>
</div>
) : null}
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm leading-relaxed text-grayScale-600">
{getNotificationMessage(notification) || "No message content."}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationTypeLabel(notification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<div className="mt-0.5">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px]">
{notification.level || "—"}
</Badge>
</div>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.delivery_channel || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.delivery_status || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Read status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.is_read ? "Read" : "Unread"}
</p>
</div>
{notification.receiver_type ? (
<div>
<p className="text-grayScale-400">Receiver</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.receiver_type}
</p>
</div>
) : null}
<div className="sm:col-span-2">
<p className="text-grayScale-400">Sent at</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.timestamp)}
</p>
</div>
{isMeaningfulExpiry(notification.expires) ? (
<div className="sm:col-span-2">
<p className="text-grayScale-400">Expires</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.expires)}
</p>
</div>
) : null}
</div>
{notification.payload.tags && notification.payload.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{notification.payload.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
</div>
) : null}
</div>
</>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@ -1,74 +1,32 @@
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Bell,
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon"
import { getNotificationById } from "../../api/notifications.api"
import { useNotifications } from "../../hooks/useNotifications"
import { NotificationDetailDialog } from "../notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function NotificationItem({
notification,
onOpen,
onMarkRead,
onMarkUnread,
}: {
notification: Notification
onOpen: (notification: Notification) => void
onMarkRead: (id: string) => void
onMarkUnread: (id: string) => void
}) {
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = cfg.icon
return (
@ -77,31 +35,26 @@ function NotificationItem({
className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
)}
onClick={() => {
if (!notification.is_read) onMarkRead(notification.id)
}}
onClick={() => onOpen(notification)}
>
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
)}
{/* Type icon */}
<span
className={cn(
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
cfg.bg
cfg.bg,
)}
>
<Icon className={cn("h-4 w-4", cfg.color)} />
</span>
{/* Content */}
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm leading-snug text-grayScale-900",
!notification.is_read && "font-semibold"
!notification.is_read && "font-semibold",
)}
>
{getNotificationTitle(notification) || "Notification"}
@ -110,11 +63,10 @@ function NotificationItem({
{getNotificationMessage(notification) || "No preview text available."}
</p>
<p className="mt-1 text-[11px] text-grayScale-600">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(notification.timestamp)}
</p>
</div>
{/* Read / Unread toggle */}
<button
type="button"
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
@ -140,6 +92,11 @@ function NotificationItem({
export function NotificationDropdown() {
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const {
@ -151,7 +108,40 @@ export function NotificationDropdown() {
markAllAsRead,
} = useNotifications()
// Click-outside handler
const loadNotificationDetail = useCallback(async (id: string, markReadIfNeeded: boolean) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (markReadIfNeeded && !res.data.is_read) {
void markOneRead(id)
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [markOneRead])
const handleOpenNotification = useCallback(
(notification: Notification) => {
setOpen(false)
void loadNotificationDetail(notification.id, !notification.is_read)
},
[loadNotificationDetail],
)
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@ -165,8 +155,8 @@ export function NotificationDropdown() {
}, [open])
return (
<>
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
@ -181,15 +171,11 @@ export function NotificationDropdown() {
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">
Notifications
</h3>
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
{unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
@ -208,7 +194,6 @@ export function NotificationDropdown() {
)}
</div>
{/* Body */}
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
@ -225,6 +210,7 @@ export function NotificationDropdown() {
<NotificationItem
key={n.id}
notification={n}
onOpen={handleOpenNotification}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
@ -233,7 +219,6 @@ export function NotificationDropdown() {
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
@ -249,5 +234,19 @@ export function NotificationDropdown() {
</div>
)}
</div>
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId, false)
: undefined
}
/>
</>
)
}

View File

@ -3,12 +3,13 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
import type { Notification } from "../types/notification.types"
const MAX_DROPDOWN = 5
const RECONNECT_MS = 5000
function getWsUrl() {
const base = import.meta.env.VITE_API_BASE_URL as string
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
const token = localStorage.getItem("access_token") ?? ""
return `${wsBase}/ws/connect?token=${token}`
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
}
export function useNotifications() {
@ -18,6 +19,8 @@ export function useNotifications() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const intentionalCloseRef = useRef(false)
const connectAttemptRef = useRef(0)
const dispatchUpdate = () => {
window.dispatchEvent(new Event("notifications-updated"))
@ -40,11 +43,37 @@ export function useNotifications() {
}
}, [])
const connectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
const clearReconnectTimer = useCallback(() => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current)
reconnectTimer.current = null
}
}, [])
const disconnectWs = useCallback(
(intentional: boolean) => {
intentionalCloseRef.current = intentional
clearReconnectTimer()
const ws = wsRef.current
wsRef.current = null
if (!ws) return
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close()
}
},
[clearReconnectTimer],
)
const connectWs = useCallback(() => {
if (!mountedRef.current) return
const token = localStorage.getItem("access_token")?.trim()
if (!token) return
disconnectWs(true)
intentionalCloseRef.current = false
const attempt = ++connectAttemptRef.current
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
@ -78,47 +107,45 @@ export function useNotifications() {
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
if (!mountedRef.current) return
if (connectAttemptRef.current !== attempt) return
if (wsRef.current === ws) wsRef.current = null
if (!mountedRef.current || intentionalCloseRef.current) return
clearReconnectTimer()
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs()
}, 5000)
}, RECONNECT_MS)
}
}, [])
}, [clearReconnectTimer, disconnectWs])
useEffect(() => {
mountedRef.current = true
intentionalCloseRef.current = false
fetchData()
connectWs()
return () => {
mountedRef.current = false
wsRef.current?.close()
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
disconnectWs(true)
}
}, [fetchData, connectWs])
}, [fetchData, connectWs, disconnectWs])
const markOneRead = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
)
setUnreadCount((prev) => Math.max(0, prev - 1))
dispatchUpdate()
try {
await markAsRead(id)
} catch {
// revert on failure
await fetchData()
}
}, [fetchData])
const markOneUnread = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n)),
)
setUnreadCount((prev) => prev + 1)
dispatchUpdate()

View File

@ -0,0 +1,57 @@
export type DynamicTableValue = {
columns: string[]
rows: string[][]
}
const DEFAULT_TABLE: DynamicTableValue = {
columns: ["Column 1", "Column 2"],
rows: [["", ""]],
}
function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
}
function normalizeRows(columns: string[], rows: unknown): string[][] {
const colCount = Math.max(1, columns.length)
if (!Array.isArray(rows)) return [Array(colCount).fill("")]
return rows.map((row) => {
if (!Array.isArray(row)) return Array(colCount).fill("")
const cells = row.map((c) => String(c ?? ""))
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
}
export function parseTableSlotValue(raw: string | undefined): DynamicTableValue {
const t = (raw ?? "").trim()
if (!t) return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
try {
const parsed = JSON.parse(t) as unknown
if (isRecord(parsed) && Array.isArray(parsed.columns)) {
const columns = parsed.columns.map((c) => String(c ?? "").trim() || "Column")
if (columns.length === 0) columns.push("Column 1")
const rows = normalizeRows(columns, parsed.rows)
return { columns, rows: rows.length > 0 ? rows : [Array(columns.length).fill("")] }
}
} catch {
/* fall through */
}
return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
}
export function serializeTableSlotValue(table: DynamicTableValue): string {
const columns = table.columns.map((c) => c.trim() || "Column")
const rows = normalizeRows(columns, table.rows).map((row) =>
row.map((cell) => cell.trim()),
)
return JSON.stringify({ columns, rows })
}
export function createEmptyTable(columnCount = 2, rowCount = 1): DynamicTableValue {
const columns = Array.from({ length: Math.max(1, columnCount) }, (_, i) => `Column ${i + 1}`)
const rows = Array.from({ length: Math.max(1, rowCount) }, () => Array(columns.length).fill(""))
return { columns, rows }
}

View File

@ -1,7 +1,16 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
function defaultValueForSchemaSlot(kind: string): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
return ""
}
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
@ -10,11 +19,55 @@ export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
for (const r of def.response_schema) o[`response:${r.id}`] = ""
for (const r of def.stimulus_schema) {
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
return o
}
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
@ -41,6 +94,24 @@ export interface LearnEnglishDefinitionQuestionInput {
sampleAnswerVoiceUrl?: string
}
export function questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
@ -51,13 +122,17 @@ export function buildCreateQuestionFromDefinition(
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues: q.dynamicFieldValues ?? {},
fieldValues,
})
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
@ -118,9 +193,7 @@ export function buildCreateQuestionFromDefinition(
}
}
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
@ -136,24 +209,36 @@ export function validateDefinitionQuestion(
index1Based: number,
): string | null {
const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
return null
}
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())

View File

@ -9,6 +9,7 @@ import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const filled = questions.filter((q) => q.questionText.trim())
if (filled.length === 0) return "Add at least one question with prompt text."
const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
@ -100,7 +104,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
)
}
const toCreate = opts.questions.filter((q) => q.questionText.trim())
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)

View File

@ -0,0 +1,101 @@
import {
Bell,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
} from "lucide-react"
export const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ icon: React.ElementType; color: string; bg: string }
> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
export const DEFAULT_NOTIFICATION_TYPE_CONFIG = {
icon: Bell,
color: "text-grayScale-500",
bg: "bg-grayScale-100",
}
export function getNotificationLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
export function formatNotificationTimestamp(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
export function formatNotificationTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
export function formatNotificationDateTime(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function isMeaningfulExpiry(expires: string) {
if (!expires) return false
const date = new Date(expires)
if (Number.isNaN(date.getTime())) return false
return date.getFullYear() > 1
}

View File

@ -0,0 +1,41 @@
/** Author-facing default labels for dynamic schema slots. */
const KIND_DEFAULT_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question prompt",
PREP_TIME: "Preparation time (seconds)",
INSTRUCTION: "Instructions",
AUDIO_PROMPT: "Audio",
TEXT_PASSAGE: "Reading passage",
IMAGE: "Image",
MATCHING_INPUTS: "Matching inputs",
SELECT_MISSING_WORDS: "Select missing words",
TABLE: "Reference table",
PDF_ATTACHMENT: "PDF document",
AUDIO_RESPONSE: "Audio response",
TEXT_INPUT: "Text input",
SHORT_ANSWER: "Short answer",
MULTIPLE_CHOICE: "Multiple choice",
OPTION: "Answer choices",
ANSWER_TIMER: "Time limit (seconds)",
PDF_UPLOAD: "PDF upload",
MATCHING_ANSWER: "Matching answer",
LABEL_SELECTION: "Label selection",
SEQUENCE_ORDER: "Sequence order",
}
export function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function defaultLabelForKind(kind: string): string {
const k = kind.trim()
return KIND_DEFAULT_LABELS[k] ?? humanizeKind(k)
}
export function slotLabel(schema: { label?: string | null; kind: string }): string {
const trimmed = schema.label?.trim()
if (trimmed) return trimmed
return defaultLabelForKind(schema.kind)
}

View File

@ -0,0 +1,6 @@
/** Standard page-size choices for admin data tables. */
export const TABLE_PAGE_SIZE_OPTIONS = [5, 10, 30, 50, 100] as const
export type TablePageSize = (typeof TABLE_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_TABLE_PAGE_SIZE: TablePageSize = 10

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import {
Bell,
Eye,
EyeOff,
Globe,
@ -23,7 +22,6 @@ import {
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { changeTeamMemberPassword } from "../api/team.api";
@ -41,7 +39,6 @@ type SettingsTab =
| "app-versions"
| "profile"
| "security"
| "notifications"
| "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
@ -49,65 +46,9 @@ const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "app-versions", label: "App versions", icon: Smartphone },
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Appearance", icon: Palette },
];
function Toggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
enabled ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
);
}
function SettingRow({
icon: Icon,
title,
description,
children,
}: {
icon: any;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-800">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
@ -623,7 +564,6 @@ export function SettingsPage() {
{activeTab === "app-versions" && <AppVersionsTab />}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />}
</main>
</div>

View File

@ -373,7 +373,19 @@ export function AddNewPracticePage() {
})
: undefined;
const qRes = await createQuestion({
const qRes = await createQuestion(
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type: "DYNAMIC",
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
}
: {
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
@ -382,21 +394,13 @@ export function AddNewPracticePage() {
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt:
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
}
: {}),
});
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
},
);
const questionId = qRes.data?.data?.id;
if (questionId) {

View File

@ -150,7 +150,7 @@ export function AddPracticeFlow() {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const list = await getQuestionTypeDefinitions({
const { definitions: list } = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});
@ -216,9 +216,7 @@ export function AddPracticeFlow() {
return;
}
const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
const mappedQuestions = formData.questions.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/>
);
case 2:
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a practice: question types from{" "}
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
Create a practice with story details, a persona, and questions from your question type library.
</p>
{lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">

View File

@ -141,8 +141,8 @@ export function AddQuestionPage() {
let cancelled = false
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
}
@ -268,7 +268,7 @@ export function AddQuestionPage() {
return
}
} catch {
toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." })
toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
return
}
}
@ -419,7 +419,7 @@ export function AddQuestionPage() {
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
dynamic_payload (JSON) <span className="text-red-500">*</span>
Dynamic content (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}

View File

@ -28,6 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string }
@ -422,7 +423,7 @@ export function AllCoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -427,11 +427,7 @@ export function CourseDetailPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle>
<DialogDescription>
Update name, sort order, and icon (upload or URL). Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
Update name, sort order, and icon (upload or URL).
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -39,6 +39,7 @@ import {
} from "../../api/courses.api"
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
@ -513,7 +514,7 @@ export function CoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -20,6 +20,7 @@ import type {
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
buildValidateKindsPayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
@ -29,6 +30,7 @@ import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/Ques
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
@ -45,7 +47,7 @@ function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: k.replace(/_/g, " "),
label: defaultLabelForKind(k),
required: true as boolean,
}))
}
@ -58,8 +60,14 @@ function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionC
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
response_schema: (def.response_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
}
}
@ -75,9 +83,9 @@ export function CreateQuestionTypeFlow() {
const [currentStep, setCurrentStep] = useState(1)
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
const [versionName, setVersionName] = useState("Test 1")
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
const [definitionReady, setDefinitionReady] = useState(!isEdit)
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
stimulus_component_kinds: [],
@ -134,7 +142,7 @@ export function CreateQuestionTypeFlow() {
return
}
setDraft(definitionToDraft(def))
setVersionName("Test 1")
setIsSystemDefinition(Boolean(def.is_system))
setCurrentStep(1)
setStepErrors({})
} catch (e) {
@ -155,7 +163,6 @@ export function CreateQuestionTypeFlow() {
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
@ -179,15 +186,10 @@ export function CreateQuestionTypeFlow() {
}
const handleNextFromStep2 = () => {
const versionErr: FieldErrorMap = {}
if (!versionName.trim()) {
versionErr.version_name = "Version name is required."
}
const eKinds = validateDefinitionKinds(draft, componentCatalog)
const mergedKinds = { ...versionErr, ...eKinds }
setStepErrors(mergedKinds)
if (Object.keys(mergedKinds).length) {
toast.error("Complete version name and component selections.")
setStepErrors(eKinds)
if (Object.keys(eKinds).length) {
toast.error("Select valid stimulus and response component kinds.")
return
}
@ -233,7 +235,7 @@ export function CreateQuestionTypeFlow() {
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
@ -290,20 +292,9 @@ export function CreateQuestionTypeFlow() {
{isEdit ? "Edit question type definition" : "Create question type definition"}
</h1>
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit ? (
<>
Update definition{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
{isEdit
? `Update reusable question type definition #${editDefinitionId}.`
: "Build a reusable question type template for dynamic practice and assessment questions."}
</p>
</div>
<div className="flex items-center gap-4 shrink-0">
@ -343,8 +334,6 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeConfigStep
draft={draft}
setDraft={setDraft}
versionName={versionName}
setVersionName={setVersionName}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading}
@ -358,7 +347,12 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
<QuestionTypeReviewPublishStep
draft={draft}
onBack={handleBack}
editDefinitionId={editDefinitionId}
isSystem={isSystemDefinition}
/>
)}
</div>
</div>

View File

@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
<div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500">
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
Choose a sub-category from the list to view and manage its course structure.
</p>
</div>
</CardContent>
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Create module</DialogTitle>
<DialogDescription>
Add a module to this level. This will call `POST /course-management/modules`.
Add a module to this level.
</DialogDescription>
</DialogHeader>
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Update module</DialogTitle>
<DialogDescription>
Update this module using `PUT /course-management/modules/:moduleId`.
Update this module&apos;s name, order, and settings.
</DialogDescription>
</DialogHeader>

View File

@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
<DialogHeader>
<DialogTitle>Lesson detail</DialogTitle>
<DialogDescription>
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
View and edit lesson details.
</DialogDescription>
</DialogHeader>

View File

@ -356,15 +356,8 @@ export function LearnEnglishPage() {
Add New Program
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a learning program via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs
</code>
. Thumbnail can be a URL or a file uploaded through{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
Create a new learning program. Add a thumbnail as an image URL or by uploading a
file.
</DialogDescription>
</DialogHeader>
{/* Gradient Divider */}
@ -739,11 +732,7 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Local images are sent to{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
Uploaded images are stored and used as the program thumbnail.
</p>
</div>
</div>

View File

@ -364,12 +364,6 @@ export function LessonPracticesPage() {
total={practices.length}
/>
))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div>
)}
</div>

View File

@ -668,15 +668,7 @@ export function ModuleDetailPage() {
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
Update lesson details. Uploaded video and thumbnail files are stored automatically.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">

View File

@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
placeholder="Optional"
/>
</div>
<p className="text-xs text-grayScale-500">
Uses <span className="font-mono">PUT /practices/&#123;id&#125;</span> with the fields above.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
<DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader>
<p className="text-sm text-grayScale-600">
This will call <span className="font-mono">DELETE /practices/&#123;id&#125;</span> and remove the practice
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
cascades.
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
question set may remain unless you remove it separately.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button

View File

@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
@ -84,7 +85,7 @@ export function PracticeQuestionsPage() {
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize] = useState(10)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0)
@ -736,11 +737,37 @@ export function PracticeQuestionsPage() {
))}
</div>
))}
{totalQuestions > pageSize && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
<p className="text-sm text-grayScale-500">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
</p>
{totalQuestions > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
void fetchQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{totalQuestions > pageSize ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -759,6 +786,7 @@ export function PracticeQuestionsPage() {
Next
</Button>
</div>
) : null}
</div>
)}
</div>

View File

@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
Add New Course
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a course via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
Add a new course to this program. Use an image URL or upload a file for the
thumbnail.
</DialogDescription>
</DialogHeader>
@ -708,11 +701,7 @@ export function ProgramCoursesPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update name, sort order, and thumbnail. Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
Update name, sort order, and thumbnail.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -1,10 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
import {
ArrowLeft,
ChevronDown,
ChevronLeft,
ChevronRight,
Layers,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
@ -15,6 +26,7 @@ import {
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
@ -22,6 +34,23 @@ import {
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type StatusFilter = "All" | "ACTIVE" | "INACTIVE"
type ScopeFilter = "all" | "system" | "custom"
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
]
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
{ value: "all", label: "All types" },
{ value: "system", label: "System only" },
{ value: "custom", label: "Custom only" },
]
const SYSTEM_SCOPE_FETCH_LIMIT = 100
export function QuestionTypeLibraryPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
@ -30,24 +59,48 @@ export function QuestionTypeLibraryPage() {
const [loading, setLoading] = useState(true)
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
const [query, setQuery] = useState("")
const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [offset, setOffset] = useState(0)
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const hasActiveFilters =
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
const load = useCallback(async () => {
setLoading(true)
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
setDefinitions(Array.isArray(rows) ? rows : [])
const isSystemScope = scopeFilter === "system"
const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
include_system: scopeFilter !== "custom",
...(statusFilter !== "All" ? { status: statusFilter } : {}),
limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
offset: isSystemScope ? 0 : offset,
})
const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
setDefinitions(visibleRows)
if (isSystemScope) {
setTotalCount(visibleRows.length)
} else if (total_count != null) {
setTotalCount(total_count)
} else if (rows.length < pageSize) {
setTotalCount(offset + rows.length)
} else {
setTotalCount(undefined)
}
} catch (e) {
console.error(e)
toast.error("Failed to load question type definitions")
setDefinitions([])
setTotalCount(0)
} finally {
setLoading(false)
}
}, [])
}, [offset, pageSize, scopeFilter, statusFilter])
useEffect(() => {
void load()
@ -67,18 +120,36 @@ export function QuestionTypeLibraryPage() {
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return definitions
return definitions.filter((d) => {
if (activeTab !== "All") {
const st = (d.status || "").toString().toUpperCase()
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
}
if (!q) return true
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query, activeTab])
}, [definitions, query])
const isSystemScope = scopeFilter === "system"
const canPrev = !isSystemScope && offset > 0
const canNext =
!isSystemScope &&
(totalCount != null ? offset + pageSize < totalCount : definitions.length === pageSize)
const pageStart = totalCount === 0 ? 0 : isSystemScope ? 1 : offset + 1
const pageEnd =
isSystemScope
? filtered.length
: totalCount != null
? Math.min(offset + definitions.length, totalCount)
: offset + definitions.length
const resetPagination = () => setOffset(0)
const clearFilters = () => {
setQuery("")
setStatusFilter("All")
setScopeFilter("all")
setOffset(0)
}
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
@ -124,9 +195,7 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
Reusable templates that define how practice and assessment questions are structured and answered.
</p>
</div>
<Link to="/new-content/question-types/create">
@ -138,48 +207,151 @@ export function QuestionTypeLibraryPage() {
</div>
</div>
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Layers className="h-5 w-5" aria-hidden />
</div>
<div>
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
<p className="text-xs text-grayScale-500 mt-0.5">
Browse and filter templates from the question type catalog
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by display name, key, or id…"
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
placeholder="Search by display name, key, or id on this page…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
activeTab === tab
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
>
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
</button>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</span>
{STATUS_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={statusFilter === value}
disabled={loading}
onClick={() => {
setStatusFilter(value)
resetPagination()
}}
/>
))}
</div>
</Card>
<span className="hidden h-5 w-px bg-grayScale-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Scope
</span>
{SCOPE_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={scopeFilter === value}
disabled={loading}
onClick={() => {
setScopeFilter(value)
resetPagination()
}}
/>
))}
</div>
</div>
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 self-start rounded-[8px] px-3 text-xs font-semibold text-grayScale-500 hover:text-brand-600 lg:self-center"
disabled={loading}
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
</div>
{loading ? (
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p>
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading definitions</p>
</div>
) : filtered.length === 0 ? (
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
</Card>
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
<Layers className="h-7 w-7" aria-hidden />
</div>
<p className="text-sm font-semibold text-grayScale-700">
{hasActiveFilters ? "No definitions match your filters" : "No definitions yet"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{hasActiveFilters
? "Try different filters or clear them to see more results."
: "Create a definition to start building custom question templates."}
</p>
{hasActiveFilters ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px]"
onClick={clearFilters}
>
Clear filters
</Button>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<Link to="/new-content/question-types/create">
<Button size="sm" className="rounded-[8px] bg-brand-600 hover:bg-brand-500">
<Plus className="mr-2 h-4 w-4" />
Create definition
</Button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
@ -198,6 +370,78 @@ export function QuestionTypeLibraryPage() {
</div>
)}
{!loading && (definitions.length > 0 || offset > 0) ? (
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-grayScale-100 bg-white px-6 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
<span>
{totalCount != null
? `Showing ${pageStart}${pageEnd} of ${totalCount}`
: `Showing ${pageStart}${pageEnd}`}
</span>
{query.trim() && filtered.length !== definitions.length ? (
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-[11px] font-semibold text-brand-600">
{filtered.length} match{filtered.length === 1 ? "" : "es"} on this page
</span>
) : null}
{!isSystemScope ? (
<>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-[8px] border border-grayScale-200 bg-white pl-2.5 pr-8 text-sm font-medium text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-brand-200"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</>
) : null}
</div>
{!isSystemScope ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
@ -244,3 +488,32 @@ export function QuestionTypeLibraryPage() {
</div>
)
}
function FilterChip({
label,
active,
disabled,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/20"
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
disabled && "pointer-events-none opacity-50",
)}
>
{label}
</button>
)
}

View File

@ -19,6 +19,7 @@ import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -558,7 +559,7 @@ export function QuestionsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -30,6 +30,7 @@ import {
} from "../../components/ui/dropdown-menu"
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
@ -149,7 +150,7 @@ export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1)
const [audioPageSize] = useState(12)
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
@ -1510,11 +1511,37 @@ export function SpeakingPage() {
))}
</div>
))}
{audioTotalCount > 0 ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-3 py-2 text-xs text-grayScale-500 sm:text-sm">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
{audioTotalCount} total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={audioPageSize}
disabled={loading}
onChange={(e) => {
setAudioPageSize(Number(e.target.value))
void fetchAudioQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{audioTotalCount > audioPageSize ? (
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
<p className="text-xs text-grayScale-500 sm:text-sm">
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
@ -1535,6 +1562,7 @@ export function SpeakingPage() {
Next
</Button>
</div>
) : null}
</div>
) : null}
</div>

View File

@ -111,11 +111,7 @@ export function AddModuleModal({
Add New Module
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a module with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
Add a new module to this course.
</DialogDescription>
</DialogHeader>

View File

@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
/>
</div>
<p className="text-xs text-grayScale-500">
This calls <span className="font-mono">POST /question-sets</span> with{" "}
<span className="font-mono">set_type: PRACTICE</span>.
Creates a practice question set for this course, module, or lesson.
</p>
<Button type="button" onClick={handleStep1} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 2 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> add
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
<span className="font-mono">POST /questions</span>.
Add one or more audio questions to question set #{questionSetId}.
</p>
{questionRows.map((row, idx) => (
<div
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 3 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Link each question to the set with a display order using{" "}
<span className="font-mono">POST /question-sets/&#123;id&#125;/questions</span>.
Confirm the order of questions in the set.
</p>
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
{createdQuestionIds.map((qid, i) => (
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 4 && parent && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Parent:{" "}
<span className="font-mono text-xs">
{parent.kind} #{parent.id}
</span>{" "}
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
<span className="font-mono">POST /practices</span>
Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
</p>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">

View File

@ -1,4 +1,4 @@
import { Edit2, Trash2, Layers, Shield } from "lucide-react"
import { Edit2, Sparkles, Trash2, Layers, Shield } from "lucide-react"
import { Badge } from "../../../components/ui/badge"
import { Card } from "../../../components/ui/card"
import { Button } from "../../../components/ui/button"
@ -42,7 +42,12 @@ export function QuestionTypeCard({
<Shield className="h-3 w-3" />
System
</Badge>
) : null}
) : (
<Badge className="shrink-0 border-none bg-amber-50 text-amber-800 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Custom
</Badge>
)}
</div>
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>

View File

@ -15,6 +15,7 @@ interface ContextStepProps {
/** Lesson-linked practice: no title, story description, or story image on step 1. */
isLessonPractice?: boolean;
lessonTitle?: string | null;
parentSummary?: string | null;
}
/**
@ -27,6 +28,7 @@ export function ContextStep({
onCancel,
isLessonPractice = false,
lessonTitle = null,
parentSummary = null,
}: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false);
@ -62,16 +64,15 @@ export function ContextStep({
<p className="text-grayScale-600 text-base mt-3">
{isLessonPractice ? (
<>
This practice is linked to{" "}
Story fields and question set options used when saving the practice. Linked to{" "}
<span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"}
</span>
. Set optional quick tips and question order below.
.
</>
) : (
<>
Title, story, optional image, shuffle, and quick tips match the create
practice and question set APIs.
Story fields and question set options used when saving the practice.
</>
)}
</p>
@ -90,6 +91,16 @@ export function ContextStep({
</div>
<div className="space-y-8 p-10">
{parentSummary ? (
<div className="rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<p className="font-semibold text-brand-700">LMS parent</p>
<p className="mt-1">{parentSummary}</p>
<p className="mt-1 text-xs text-grayScale-500">
The question set and practice will be linked to this course, module, or lesson.
</p>
</div>
) : null}
{!isLessonPractice ? (
<>
<div className="space-y-2">
@ -132,7 +143,7 @@ export function ContextStep({
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips on POST /practices)"
placeholder="Optional tips shown to learners before they start"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>

View File

@ -15,8 +15,7 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
Publish status <span className="text-red-500">*</span>
</p>
<p className="text-xs text-grayScale-500">
Sent as <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">publish_status</code> on{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
Controls whether learners can see this practice after you save.
</p>
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
{(

View File

@ -10,6 +10,8 @@ import {
emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
import { toast } from "sonner";
function defaultMcqOptions() {
return [
@ -98,9 +100,9 @@ export function QuestionsStep({
return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
). Others: URL, text, or JSON.
<span className="font-medium text-grayScale-800">Image, audio, and PDF</span> use upload or URL.{" "}
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
prep-time slots use seconds. Other slots use text or structured JSON where noted.
</p>
{def.stimulus_schema.length > 0 ? (
<div className="space-y-2">
@ -307,11 +309,8 @@ export function QuestionsStep({
<div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg">
Question types are loaded from{" "}
<code className="rounded bg-grayScale-100 px-1 text-sm">
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
when you publish or save the practice.
</p>
</div>
@ -403,6 +402,7 @@ export function QuestionsStep({
) : null}
</div>
{def && !definitionUsesDynamicPayload(def) ? (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question text
@ -418,6 +418,12 @@ export function QuestionsStep({
placeholder="Question prompt for learners"
/>
</div>
) : def && definitionUsesDynamicPayload(def) ? (
<p className="rounded-lg border border-violet-200 bg-violet-50/60 px-3 py-2 text-xs text-violet-950">
Enter the question prompt in the text or instruction field below, along with any other
required content for this type.
</p>
) : null}
{def ? renderTypeSpecificFields(q, i, def) : null}
</div>
@ -451,7 +457,30 @@ export function QuestionsStep({
</Button>
<Button
type="button"
onClick={nextStep}
onClick={() => {
const mapped = formData.questions.map((row: typeof formData.questions[0]) => ({
questionText: String(row.text ?? "").trim(),
questionTypeDefinitionId: Number(row.questionTypeDefinitionId),
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
mcqOptions: (row.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: row.trueFalseCorrect !== false,
shortAnswers: (row.shortAnswers ?? []).map((s: string) => String(s)),
}));
const msg = validateLearnEnglishQuestionsWithDefinitions(
mapped,
typeDefinitions,
);
if (msg) {
toast.error("Check your questions", { description: msg });
return;
}
nextStep();
}}
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
>

View File

@ -0,0 +1,43 @@
import { AlertTriangle, Info } from "lucide-react"
import { inferRuntimeQuestionType } from "../../lib/questionTypeDefinitionValidation"
export function DefinitionRuntimeHint({
definitionKey,
responseKinds,
}: {
definitionKey: string
responseKinds: string[]
}) {
const runtime = inferRuntimeQuestionType(definitionKey, responseKinds)
if (runtime == null) {
return (
<div className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-600" />
<div>
<p className="font-semibold">May not be publishable</p>
<p className="mt-0.5 text-amber-800/90">
The server requires a mappable runtime question type. Add at least one non-timer response
kind (e.g. OPTION, TEXT_INPUT). Timer-only definitions are rejected.
</p>
</div>
</div>
)
}
return (
<div className="flex gap-3 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<Info className="h-5 w-5 shrink-0 text-brand-500" />
<div>
<p className="font-semibold text-grayScale-900">
Stored as: {runtime === "DYNAMIC" ? "dynamic question" : runtime.replace(/_/g, " ").toLowerCase()}
</p>
<p className="mt-0.5 text-grayScale-600">
{runtime === "DYNAMIC"
? "Practice questions built from this type use the dynamic question builder."
: "Questions built from this type use the classic question format for this kind."}
</p>
</div>
</div>
)
}

View File

@ -29,10 +29,8 @@ export function QuestionTypeBasicInfoStep({
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
<p className="text-grayScale-500 font-medium mt-1">
Set the reusable key, display name, and status. On the next step you will pick stimulus and response
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
Set the reusable key, display name, and status. On the next step you will choose how questions are
presented and how learners answer.
</p>
</div>

View File

@ -1,12 +1,5 @@
import { useState } from "react"
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Hourglass,
Plus,
} from "lucide-react"
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { Input } from "../../../../components/ui/input"
@ -16,14 +9,17 @@ import type {
} from "../../../../types/questionTypeDefinition.types"
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
import { SchemaBuilderSection } from "./SchemaBuilderSection"
import { SchemaSlotLabelsPanel } from "./SchemaSlotLabelsPanel"
import { ComponentKindCard } from "./ComponentKindCard"
import { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
interface QuestionTypeConfigStepProps {
draft: QuestionTypeDefinitionCreatePayload
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
versionName: string
setVersionName: (v: string) => void
stimulusCatalogKinds: string[]
responseCatalogKinds: string[]
catalogLoading: boolean
@ -42,10 +38,6 @@ function slugFragmentFromKind(kind: string): string {
return s.replace(/^_|_$/g, "") || "field"
}
function defaultSchemaLabel(kind: string): string {
return kind.replace(/_/g, " ")
}
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
const base = slugFragmentFromKind(kind)
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
@ -58,6 +50,22 @@ function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: strin
return id
}
function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
return [...new Set(rows.map((r) => r.kind).filter(Boolean))]
}
function removeLastSlotOfKind(
rows: DynamicElementDefinition[],
kind: string,
): DynamicElementDefinition[] {
let removeIndex = -1
rows.forEach((row, index) => {
if (row.kind === kind) removeIndex = index
})
if (removeIndex < 0) return rows
return rows.filter((_, index) => index !== removeIndex)
}
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
const prefix = `${side}_`
const out: Record<number, string> = {}
@ -73,8 +81,6 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
export function QuestionTypeConfigStep({
draft,
setDraft,
versionName,
setVersionName,
stimulusCatalogKinds,
responseCatalogKinds,
catalogLoading,
@ -83,11 +89,8 @@ export function QuestionTypeConfigStep({
onNext,
onBack,
}: QuestionTypeConfigStepProps) {
const [panelOpen, setPanelOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(false)
const title = draft.display_name?.trim() || "Untitled definition"
const handleStimulusKindClick = (kind: string) => {
setDraft((d) => {
const wasSelected = d.stimulus_component_kinds.includes(kind)
@ -98,7 +101,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -122,7 +125,7 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -143,7 +146,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
return { ...d, stimulus_schema }
@ -157,54 +160,63 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
return { ...d, response_schema }
})
}
const removeStimulusSlot = (kind: string) => {
setDraft((d) => {
const stimulus_schema = removeLastSlotOfKind(d.stimulus_schema, kind)
return {
...d,
stimulus_schema,
stimulus_component_kinds: uniqueKindsFromSchemaRows(stimulus_schema),
}
})
}
const removeResponseSlot = (kind: string) => {
setDraft((d) => {
const response_schema = removeLastSlotOfKind(d.response_schema, kind)
return {
...d,
response_schema,
response_component_kinds: uniqueKindsFromSchemaRows(response_schema),
}
})
}
const setStimulusSchema = (rows: DynamicElementDefinition[]) => {
setDraft((d) => ({
...d,
stimulus_schema: rows,
stimulus_component_kinds: uniqueKindsFromSchemaRows(rows),
}))
}
const setResponseSchema = (rows: DynamicElementDefinition[]) => {
setDraft((d) => ({
...d,
response_schema: rows,
response_component_kinds: uniqueKindsFromSchemaRows(rows),
}))
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
className="w-full flex items-center justify-between gap-4 px-5 py-4 bg-violet-100/90 hover:bg-violet-100 border-b border-violet-200/80 text-left transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded-xl bg-white/80 flex items-center justify-center text-violet-700 shrink-0 shadow-sm">
<Hourglass className="h-5 w-5" />
</div>
<span className="text-[17px] font-bold text-grayScale-900 truncate">{title}</span>
</div>
{panelOpen ? (
<ChevronUp className="h-5 w-5 text-grayScale-600 shrink-0" />
) : (
<ChevronDown className="h-5 w-5 text-grayScale-600 shrink-0" />
)}
</button>
{panelOpen ? (
<div className="p-6 sm:p-10 space-y-10">
<div className="space-y-2 max-w-xl">
<label className="text-[14px] font-semibold text-grayScale-700">
Version name <span className="text-red-500">*</span>
</label>
<Input
className="h-11 rounded-[10px] border-grayScale-200 bg-[#F8FAFC]"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="e.g. Test 1"
/>
{errors.version_name ? (
<p className="text-sm font-medium text-red-600">{errors.version_name}</p>
) : null}
<p className="text-[12px] text-grayScale-400">
Local label for this authoring pass (not sent to the API unless you add it to description later).
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input &amp; answer types</h2>
<p className="text-grayScale-500 font-medium mt-1">
Choose what learners see in the question and how they respond. Add or remove slots for each type
as needed.
</p>
</div>
<div className="p-6 sm:p-10 space-y-10">
{catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading component catalog</p>
) : catalogError ? (
@ -217,10 +229,8 @@ export function QuestionTypeConfigStep({
Section A: Question input types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
Choose how the question is presented to the learner. The API lists each kind once in{" "}
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "}
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
include the same kind multiple times (different ids).
Choose how the question is presented to the learner. Use Add slot / Remove slot to adjust
how many fields of each type you need.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -241,17 +251,32 @@ export function QuestionTypeConfigStep({
<span className="text-[12px] text-grayScale-500 font-medium">
{slotCount} slot{slotCount === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
onClick={() => removeStimulusSlot(kind)}
disabled={slotCount === 0}
aria-label={`Remove ${label} slot`}
>
<Minus className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addStimulusSlot(kind)}
aria-label={`Add ${label} slot`}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add slot
Add
</Button>
</div>
</div>
) : null}
</div>
)
@ -268,10 +293,8 @@ export function QuestionTypeConfigStep({
Section B: Answer types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
How should the student answer?{" "}
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
fields of the same kind.
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
each type needs.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -292,17 +315,32 @@ export function QuestionTypeConfigStep({
<span className="text-[12px] text-grayScale-500 font-medium">
{slotCount} slot{slotCount === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
onClick={() => removeResponseSlot(kind)}
disabled={slotCount === 0}
aria-label={`Remove ${label} slot`}
>
<Minus className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addResponseSlot(kind)}
aria-label={`Add ${label} slot`}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add slot
Add
</Button>
</div>
</div>
) : null}
</div>
)
@ -315,6 +353,14 @@ export function QuestionTypeConfigStep({
</div>
)}
<SchemaSlotLabelsPanel
stimulusRows={draft.stimulus_schema}
responseRows={draft.response_schema}
onStimulusChange={setStimulusSchema}
onResponseChange={setResponseSchema}
errors={errors}
/>
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 overflow-hidden">
<button
type="button"
@ -336,7 +382,7 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.stimulus_component_kinds}
catalogKinds={stimulusCatalogKinds}
rows={draft.stimulus_schema}
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
onChange={setStimulusSchema}
error={errors.stimulus_schema}
rowErrors={rowErrorMap("stimulus", errors)}
/>
@ -346,7 +392,7 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.response_component_kinds}
catalogKinds={responseCatalogKinds}
rows={draft.response_schema}
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
onChange={setResponseSchema}
error={errors.response_schema}
rowErrors={rowErrorMap("response", errors)}
/>
@ -354,7 +400,6 @@ export function QuestionTypeConfigStep({
) : null}
</div>
</div>
) : null}
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button

View File

@ -11,30 +11,54 @@ import {
validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
import {
buildCreatePayload,
buildValidateKindsPayload,
inferRuntimeQuestionType,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
import { slotLabel } from "./componentKindUi"
interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload
onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null
isSystem?: boolean
}
export function QuestionTypeReviewPublishStep({
draft,
onBack,
editDefinitionId,
isSystem,
}: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft)
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
const submit = async (status: "ACTIVE" | "INACTIVE") => {
if (runtime == null) {
toast.error("Definition cannot be saved", {
description: "Add at least one non-timer response kind so the server can map a runtime question type.",
})
return
}
const body = { ...payload, status }
setSubmitting(true)
try {
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
@ -45,14 +69,6 @@ export function QuestionTypeReviewPublishStep({
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
@ -81,30 +97,30 @@ export function QuestionTypeReviewPublishStep({
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review & publish</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review &amp; publish</h2>
<p className="text-grayScale-500 font-medium mt-1">
{isEdit ? (
<>
Confirm changes, then update via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">
PUT /questions/type-definitions/{editDefinitionId}
</code>
.
</>
) : (
<>
Confirm details, then create via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
{isEdit
? "Confirm your changes and save. The definition key cannot be changed."
: "Confirm your definition, then save it for use when authoring practice questions."}
</p>
</div>
<div className="p-10 space-y-6">
{isSystem ? (
<p className="text-sm font-medium text-amber-800 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
This is a system definition. You can update it, but it cannot be deleted from the library.
</p>
) : null}
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd>
<dd className="font-medium text-grayScale-900 mt-1 font-mono text-[13px]">{payload.key}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
@ -118,6 +134,10 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt>
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Runtime type</dt>
<dd className="font-medium text-grayScale-900 mt-1">{runtime ?? "Unmappable"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd>
@ -126,33 +146,42 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_schema.length}</dd>
</div>
</dl>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<SchemaSlotSummary title="Stimulus schema" rows={payload.stimulus_schema} />
<SchemaSlotSummary title="Response schema" rows={payload.response_schema} />
</div>
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="button"
variant="outline"
className="h-11"
disabled={submitting}
disabled={submitting || runtime == null}
onClick={() => void submit("INACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as inactive" : "Save as inactive (draft)"}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as inactive"
) : (
"Save as inactive (draft)"
)}
</Button>
<Button
type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting}
disabled={submitting || runtime == null}
onClick={() => void submit("ACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as active" : "Create as active"}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as active"
) : (
"Create as active"
)}
</Button>
</div>
</div>
@ -173,3 +202,35 @@ export function QuestionTypeReviewPublishStep({
</div>
)
}
function SchemaSlotSummary({
title,
rows,
}: {
title: string
rows: { id: string; kind: string; label?: string; required: boolean }[]
}) {
return (
<div className="rounded-xl border border-grayScale-100 bg-grayScale-50/50 p-4">
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
{rows.length === 0 ? (
<p className="mt-2 text-sm text-grayScale-500">No slots</p>
) : (
<ul className="mt-2 space-y-1.5 text-sm">
{rows.map((r) => (
<li key={`${r.kind}-${r.id}`} className="flex flex-wrap gap-x-2 text-grayScale-800">
<span className="font-medium">{slotLabel(r)}</span>
<span className="text-grayScale-400">·</span>
<span className="font-mono text-[11px] text-grayScale-500">{r.id}</span>
<span className="text-grayScale-400">·</span>
<span className="text-grayScale-600">{r.kind}</span>
{r.required ? (
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
) : null}
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -1,11 +1,15 @@
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
import {
buildCreatePayload,
buildValidateKindsPayload,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2)
const runValidate = async () => {
const runValidate = useCallback(async () => {
setValidating(true)
setServerOk(null)
setServerDetail(null)
try {
const res = await validateQuestionTypeDefinition(payload)
const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!res.valid) {
setServerOk(false)
const detail = res.error || res.message || "Validation failed"
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
return
}
setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.")
toast.success(res.message || "Question type definition is valid")
setServerDetail(res.message || "Component kinds are valid.")
toast.success(res.message || "Definition kinds validated")
} finally {
setValidating(false)
}
}, [draft])
useEffect(() => {
void runValidate()
}, [runValidate])
const handleNext = () => {
if (serverOk !== true) {
toast.error("Validate with the server before continuing.", {
description: serverDetail || "Fix response/stimulus kinds and try again.",
})
return
}
onNext()
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate</h2>
<p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
. Uses the same JSON as create; validity comes from <code className="text-xs bg-grayScale-100 px-1 rounded">data.valid</code>{" "}
(not the envelope <code className="text-xs bg-grayScale-100 px-1 rounded">success</code> flag).
We check that your stimulus and response selections are valid before you continue. You must pass
validation before review.
</p>
</div>
<div className="p-10 space-y-6">
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runValidate}
onClick={() => void runValidate()}
disabled={validating}
className="h-10"
>
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
Validating
</>
) : (
"Validate with server"
"Re-validate"
)}
</Button>
{serverOk === true ? (
@ -83,12 +104,49 @@ export function QuestionTypeValidatePreviewStep({
{serverDetail}
</span>
) : null}
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</span> : null}
{serverOk === false ? (
<span className="text-sm font-medium text-red-600">{serverDetail}</span>
) : validating ? (
<span className="text-sm text-grayScale-500">Checking kinds with server</span>
) : null}
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm rounded-xl border border-grayScale-100 bg-grayScale-50/60 p-4">
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.stimulus_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.stimulus_schema.length
? payload.stimulus_schema.map((r) => r.label).join(" · ")
: "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_schema.length
? payload.response_schema.map((r) => r.label).join(" · ")
: "—"}
</dd>
</div>
</dl>
<div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[420px] overflow-y-auto">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
Definition preview
</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[360px] overflow-y-auto">
{json}
</pre>
</div>
@ -106,10 +164,11 @@ export function QuestionTypeValidatePreviewStep({
</Button>
<Button
type="button"
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
onClick={handleNext}
disabled={validating || serverOk !== true}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] disabled:opacity-50 transition-all flex items-center gap-3"
>
Next: Review
Next: Review & publish
<ArrowRight className="h-5 w-5" />
</Button>
</div>

View File

@ -4,6 +4,11 @@ import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
type Side = "stimulus" | "response"
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
return {
id: "",
kind: first,
label: "",
label: first ? defaultLabelForKind(first) : "",
required: true,
config: undefined,
}
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
const next = rows.map((r, i) => {
if (i !== index) return r
const merged = { ...r, ...patch }
if (patch.kind && patch.kind !== r.kind) {
const priorDefault = defaultLabelForKind(r.kind)
const current = (r.label ?? "").trim()
if (!current || current === priorDefault) {
merged.label = defaultLabelForKind(patch.kind)
}
}
return merged
})
onChange(next)
}
const kindPresentation = (kind: string) =>
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim()
if (!trimmed) {
@ -73,9 +92,8 @@ export function SchemaBuilderSection({
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Each row defines one element in the{" "}
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
config).
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
authors see when creating questions.
</p>
</div>
<Button
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k}
{kindPresentation(k).label} ({k})
</option>
))}
</Select>
@ -154,11 +172,13 @@ export function SchemaBuilderSection({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Label</label>
<label className="text-[12px] font-semibold text-grayScale-600">
Field label <span className="text-red-500">*</span>
</label>
<Input
value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label"
placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white"
/>
</div>

View File

@ -0,0 +1,137 @@
import { Trash2 } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Input } from "../../../../components/ui/input"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
interface SchemaSlotLabelsPanelProps {
stimulusRows: DynamicElementDefinition[]
responseRows: DynamicElementDefinition[]
onStimulusChange: (rows: DynamicElementDefinition[]) => void
onResponseChange: (rows: DynamicElementDefinition[]) => void
errors?: Record<string, string>
}
function updateRowLabel(
rows: DynamicElementDefinition[],
index: number,
label: string,
): DynamicElementDefinition[] {
return rows.map((row, i) => (i === index ? { ...row, label } : row))
}
function removeRow(rows: DynamicElementDefinition[], index: number): DynamicElementDefinition[] {
return rows.filter((_, i) => i !== index)
}
function SlotLabelGroup({
title,
side,
rows,
onChange,
errors,
}: {
title: string
side: "stimulus" | "response"
rows: DynamicElementDefinition[]
onChange: (rows: DynamicElementDefinition[]) => void
errors?: Record<string, string>
}) {
if (!rows.length) return null
return (
<div className="space-y-3">
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
<div className="space-y-2">
{rows.map((row, index) => {
const presentation =
side === "stimulus"
? getStimulusKindPresentation(row.kind)
: getResponseKindPresentation(row.kind)
const rowError = errors?.[`${side}_${index}`]
return (
<div
key={`${side}-${row.id}-${index}`}
className="rounded-xl border border-grayScale-200 bg-white p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-grayScale-500">
<span className="font-semibold text-grayScale-700">{presentation.label}</span>
<span className="text-grayScale-300">·</span>
<span className="font-mono text-[11px]">{row.id}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-grayScale-500 hover:text-red-600"
onClick={() => onChange(removeRow(rows, index))}
aria-label={`Remove ${presentation.label} slot`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<label className="text-[12px] font-semibold text-grayScale-600">
Field label <span className="text-red-500">*</span>
</label>
<Input
value={row.label ?? ""}
onChange={(e) => onChange(updateRowLabel(rows, index, e.target.value))}
placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white"
/>
<p className="text-[11px] text-grayScale-400">
Shown to authors when they create questions from this type.
</p>
</div>
{rowError ? <p className="text-sm text-red-600">{rowError}</p> : null}
</div>
)
})}
</div>
</div>
)
}
export function SchemaSlotLabelsPanel({
stimulusRows,
responseRows,
onStimulusChange,
onResponseChange,
errors,
}: SchemaSlotLabelsPanelProps) {
if (!stimulusRows.length && !responseRows.length) return null
return (
<div className="rounded-xl border border-grayScale-200 bg-[#F8FAFC] p-5 space-y-6">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">Field labels</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Name each schema slot for question authors, or remove slots you no longer need. Labels are stored on
the definition and are not sent inside question payloads.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SlotLabelGroup
title="Question content (stimulus)"
side="stimulus"
rows={stimulusRows}
onChange={onStimulusChange}
errors={errors}
/>
<SlotLabelGroup
title="Learner answer (response)"
side="response"
rows={responseRows}
onChange={onResponseChange}
errors={errors}
/>
</div>
</div>
)
}

View File

@ -20,34 +20,35 @@ import {
Volume2,
} from "lucide-react"
/** Human label for API kind codes; unknown kinds fall back to title-cased code. */
import { defaultLabelForKind, humanizeKind, slotLabel } from "../../../../lib/schemaSlotLabel"
export { defaultLabelForKind, humanizeKind, slotLabel }
const STIMULUS_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question Text",
PREP_TIME: "Prep Time",
INSTRUCTION: "Instruction",
AUDIO_PROMPT: "Audio Prompt",
AUDIO_CLIP: "Audio Clip",
TEXT_PASSAGE: "Text Passage",
IMAGE: "Image",
CHART: "Chart",
MATCHING_INPUTS: "Matching Inputs",
SELECT_MISSING_WORDS: "Select Missing Words",
TABLE: "Table",
FLOW_CHART: "Flow Chart",
QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
PREP_TIME: defaultLabelForKind("PREP_TIME"),
INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
IMAGE: defaultLabelForKind("IMAGE"),
MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
TABLE: defaultLabelForKind("TABLE"),
PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
}
const RESPONSE_LABELS: Record<string, string> = {
AUDIO_RESPONSE: "Audio Response",
TEXT_INPUT: "Text Input",
SHORT_ANSWER: "Short Answer",
MULTIPLE_CHOICE: "Multiple Choice",
OPTION: "Options",
ANSWER_TIMER: "Answer Timer",
SELECT_MISSING_WORDS: "Select Missing Words",
PDF_UPLOAD: "PDF Upload",
MATCHING_ANSWER: "Matching Answer",
LABEL_SELECTION: "Label Selection",
SEQUENCE_ORDER: "Sequence Order",
AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
OPTION: defaultLabelForKind("OPTION"),
ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
}
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
@ -59,11 +60,10 @@ const STIMULUS_ICONS: Record<string, LucideIcon> = {
AUDIO_CLIP: Volume2,
TEXT_PASSAGE: FileText,
IMAGE: ImageIcon,
CHART: BarChart3,
MATCHING_INPUTS: Link2,
SELECT_MISSING_WORDS: ListTodo,
TABLE: TableIcon,
FLOW_CHART: GitBranch,
PDF_ATTACHMENT: FileUp,
}
const RESPONSE_ICONS: Record<string, LucideIcon> = {
@ -82,13 +82,6 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
const DEFAULT_ICON = FileText
function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
return {
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),

View File

@ -104,12 +104,8 @@ export function VideoDetailStep({
Video
</h3>
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
sent to your storage via{" "}
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
POST /files/upload
</code>
.
Upload a file or paste a link (Vimeo, hosted file, etc.). Uploaded files are stored
automatically.
</p>
<LessonMediaUploadField
kind="video"
@ -260,12 +256,8 @@ export function VideoDetailStep({
Pro tip
</h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
Use clear titles and a thumbnail that matches the lesson. The
lesson is created with{" "}
<code className="rounded bg-white/80 px-1 text-[10px]">
POST /modules/:moduleId/lessons
</code>{" "}
when you publish.
Use clear titles and a thumbnail that matches the lesson. The lesson is created when
you publish.
</p>
</div>
</div>

View File

@ -3,6 +3,7 @@ import type {
QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types"
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
export type FieldErrorMap = Record<string, string>
@ -41,6 +42,18 @@ export function validateDefinitionKinds(
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
}
const prepCount = sk.filter((k) => k === "PREP_TIME").length
if (prepCount > 1) {
errors.stimulus_kinds = "At most one PREP_TIME is allowed."
}
const timerCount = rk.filter((k) => k === "ANSWER_TIMER").length
if (timerCount > 1) {
errors.response_kinds = errors.response_kinds
? `${errors.response_kinds} At most one ANSWER_TIMER is allowed.`
: "At most one ANSWER_TIMER is allowed."
}
if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds)
@ -87,13 +100,18 @@ export function validateDefinitionSchemas(
const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => {
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
const rowMessages: string[] = []
if (!row.kind) rowMessages.push("Kind is required.")
else if (allowedSet.size && !allowedSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
rowMessages.push(`Kind "${row.kind}" is not in selected ${side} kinds.`)
}
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
rowMessages.push(`Kind "${row.kind}" is not in the ${side} component catalog.`)
}
if (!row.label?.trim()) {
rowMessages.push("Label is required — this is the field title authors see when creating questions.")
}
if (rowMessages.length) errors[`${prefix}_${i}`] = rowMessages.join(" ")
})
}
@ -126,6 +144,42 @@ function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
return [...set].sort((a, b) => a.localeCompare(b))
}
const AUXILIARY_RESPONSE_KINDS = new Set(["ANSWER_TIMER"])
const SHORT_ANSWER_RESPONSE_KINDS = new Set([
"SHORT_ANSWER",
"TEXT_INPUT",
"SELECT_MISSING_WORDS",
"MATCHING_ANSWER",
"LABEL_SELECTION",
"PDF_UPLOAD",
])
/** Mirrors server runtime mapping (§13). Returns null when create would fail as unmappable. */
export function inferRuntimeQuestionType(
key: string,
responseKinds: string[],
): "TRUE_FALSE" | "AUDIO" | "MCQ" | "SHORT_ANSWER" | "DYNAMIC" | null {
const normalizedKey = key.trim().toLowerCase()
if (normalizedKey === "true_false") return "TRUE_FALSE"
if (responseKinds.includes("AUDIO_RESPONSE")) return "AUDIO"
if (responseKinds.includes("MULTIPLE_CHOICE")) return "MCQ"
const nonAuxiliary = responseKinds.filter((k) => !AUXILIARY_RESPONSE_KINDS.has(k))
if (nonAuxiliary.some((k) => SHORT_ANSWER_RESPONSE_KINDS.has(k))) return "SHORT_ANSWER"
if (nonAuxiliary.length > 0) return "DYNAMIC"
return null
}
/** POST /questions/validate-question-type-definition — kinds only. */
export function buildValidateKindsPayload(
draft: QuestionTypeDefinitionCreatePayload,
): Pick<QuestionTypeDefinitionCreatePayload, "stimulus_component_kinds" | "response_component_kinds"> {
const payload = buildCreatePayload(draft)
return {
stimulus_component_kinds: payload.stimulus_component_kinds,
response_component_kinds: payload.response_component_kinds,
}
}
export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload {
@ -133,14 +187,14 @@ export function buildCreatePayload(
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
config: r.config && Object.keys(r.config).length ? r.config : undefined,
}))
const response_schema = draft.response_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
config: r.config && Object.keys(r.config).length ? r.config : undefined,
}))

View File

@ -41,6 +41,7 @@ import {
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import {
getIssues,
@ -654,7 +655,7 @@ export function IssuesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -119,11 +119,7 @@ export function CreateEmailTemplatePage() {
New custom template
</h1>
<p className="max-w-2xl text-sm text-grayScale-500">
Create a custom template via{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
POST /admin/email-templates
</code>
. System templates are managed separately.
Create a custom email template. System templates are managed separately.
</p>
</div>

View File

@ -78,11 +78,8 @@ export function EmailTemplatesPage() {
Email Templates
</h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Templates from{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
GET /admin/email-templates
</code>
. Open a template for full preview via slug API.
View and edit email templates used for learner and team notifications. Open a template to
preview and edit its content.
</p>
</div>
<div className="flex flex-wrap gap-2">

View File

@ -3,17 +3,9 @@ import {
Bell,
BellOff,
AlertTriangle,
Info,
AlertCircle,
CheckCircle2,
ChevronLeft,
ChevronRight,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
import {
getNotificationById,
getNotifications,
getUnreadCount,
markAsRead,
@ -63,6 +56,14 @@ import {
sendBulkEmail,
sendBulkPush,
} from "../../api/notifications.api"
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api"
import { getUsers } from "../../api/users.api"
@ -71,70 +72,7 @@ import type { Role } from "../../types/rbac.types"
import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner"
const PAGE_SIZE = 10
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function getLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function formatTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
@ -149,7 +87,7 @@ function NotificationItem({
onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean
}) {
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
@ -190,7 +128,7 @@ function NotificationItem({
>
{getNotificationTitle(notification)}
</span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
</Badge>
</div>
@ -206,7 +144,7 @@ function NotificationItem({
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(notification.timestamp)}
</span>
<button
type="button"
@ -236,7 +174,7 @@ function NotificationItem({
{/* Meta row */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{formatTypeLabel(notification.type)}
{formatNotificationTypeLabel(notification.type)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel}
@ -265,12 +203,16 @@ export function NotificationsPage() {
const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
@ -429,7 +371,7 @@ export function NotificationsPage() {
setError(false)
try {
const [notifRes, unreadRes] = await Promise.all([
getNotifications(PAGE_SIZE, currentOffset),
getNotifications(pageSize, currentOffset),
getUnreadCount(),
])
setNotifications(notifRes.data.notifications ?? [])
@ -440,11 +382,11 @@ export function NotificationsPage() {
} finally {
setLoading(false)
}
}, [])
}, [pageSize])
useEffect(() => {
fetchData(offset)
}, [offset, fetchData])
}, [offset, pageSize, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id))
@ -495,10 +437,10 @@ export function NotificationsPage() {
}
}, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const currentPage = Math.floor(offset / pageSize) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + PAGE_SIZE, totalCount)
const endEntry = Math.min(offset + pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
@ -525,7 +467,7 @@ export function NotificationsPage() {
const haystack = [
getNotificationTitle(n),
getNotificationMessage(n),
formatTypeLabel(n.type),
formatNotificationTypeLabel(n.type),
n.delivery_channel,
n.level,
]
@ -537,9 +479,42 @@ export function NotificationsPage() {
return true
})
const handleOpenDetail = (notification: Notification) => {
setSelectedNotification(notification)
const loadNotificationDetail = useCallback(async (id: string) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (!res.data.is_read) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
)
setGlobalUnread((prev) => Math.max(0, prev - 1))
try {
await markAsRead(id)
} catch {
// list refresh on next load will reconcile
}
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [])
const handleOpenDetail = (notification: Notification) => {
void loadNotificationDetail(notification.id)
}
return (
@ -756,7 +731,7 @@ export function NotificationsPage() {
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
@ -766,7 +741,7 @@ export function NotificationsPage() {
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}>
{formatTypeLabel(t)}
{formatNotificationTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -830,7 +805,7 @@ export function NotificationsPage() {
</TableRow>
) : (
filteredNotifications.map((n) => {
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
@ -854,7 +829,7 @@ export function NotificationsPage() {
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)}
{formatNotificationTypeLabel(n.type)}
</span>
</div>
</TableCell>
@ -880,7 +855,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell>
<Badge
variant={getLevelBadge(n.level)}
variant={getNotificationLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
@ -888,7 +863,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)}
{formatNotificationTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
@ -941,18 +916,25 @@ export function NotificationsPage() {
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={PAGE_SIZE}
disabled
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
<option value={PAGE_SIZE}>{PAGE_SIZE}</option>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
disabled={currentPage <= 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
@ -970,7 +952,7 @@ export function NotificationsPage() {
<button
key={n}
type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
onClick={() => setOffset((n - 1) * pageSize)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage
@ -983,7 +965,7 @@ export function NotificationsPage() {
),
)}
<button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
disabled={currentPage >= totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
@ -998,66 +980,18 @@ export function NotificationsPage() {
</>
)}
{/* Detail dialog */}
{selectedNotification && (
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{(() => {
const Icon =
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
return <Icon className="h-4 w-4" />
})()}
</span>
<span className="truncate text-base">
{getNotificationTitle(selectedNotification)}
</span>
</DialogTitle>
<DialogDescription>
Sent via {selectedNotification.delivery_channel} ·{" "}
{formatTimestamp(selectedNotification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">
{getNotificationMessage(selectedNotification)}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatTypeLabel(selectedNotification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.level}
</p>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
{selectedNotification.delivery_channel}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.delivery_status}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)}
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId)
: undefined
}
/>
{/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>

View File

@ -185,10 +185,7 @@ export function EmailTemplateCreateForm({
<Button variant="outline" disabled={saving} onClick={onReset}>
Reset
</Button>
<p className="text-xs text-grayScale-400">
Saved with{" "}
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
</p>
<p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
</div>
</div>
)

View File

@ -102,11 +102,7 @@ export function EmailTemplateEditForm({
<p className="mt-2 text-xs text-grayScale-400">
Use Go template syntax, e.g.{" "}
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
Saved with{" "}
<code className="rounded bg-grayScale-100 px-1">
PUT /admin/email-templates/{template.id}
</code>
.
Your changes are saved when you submit the form.
</p>
</div>

View File

@ -41,7 +41,7 @@ import type {
PaymentStatus,
} from "../../types/payment.types"
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
{ value: "SUCCESS", label: "Success" },
@ -168,8 +168,7 @@ export function PaymentsPage() {
</p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Browse checkout transactions from{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
Browse and filter checkout transactions from Chapa, Arifpay, and other providers.
</p>
</div>
<Button
@ -439,7 +438,7 @@ export function PaymentsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{PAGE_SIZE_OPTIONS.map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -5,6 +5,7 @@ import {
Search,
Shield,
ShieldCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
@ -37,6 +38,7 @@ import {
} from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
@ -49,7 +51,7 @@ export function RolesListPage() {
const [roles, setRoles] = useState<Role[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const [loading, setLoading] = useState(true)
@ -543,11 +545,35 @@ export function RolesListPage() {
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
<p className="text-xs text-grayScale-400">
{total > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles
</p>
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
@ -571,6 +597,7 @@ export function RolesListPage() {
<ChevronRight className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
)}
</>

View File

@ -153,12 +153,8 @@ export function InviteTeamMemberDialog({
Invite team members
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
Sends one{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
POST /team/members/invite
</code>{" "}
request per email. Invitees complete setup at{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">/accept-invite</code>
Send one invitation per email address. Invitees complete account setup using the link in
their email
{roleLocked ? (
<>
{" "}

View File

@ -3,6 +3,7 @@ import {
AlertTriangle,
Apple,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ExternalLink,
@ -22,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import {
formatAppPlatform,
formatAppVersionCreatedAt,
@ -34,8 +36,6 @@ import { CreateAppVersionDialog } from "./components/CreateAppVersionDialog"
import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog"
import { EditAppVersionDialog } from "./components/EditAppVersionDialog"
const PAGE_SIZE = 20
function PlatformIcon({ platform }: { platform: string }) {
const upper = platform.toUpperCase()
if (upper === "IOS") {
@ -64,6 +64,7 @@ export function AppVersionsTab() {
const [versions, setVersions] = useState<AppVersion[]>([])
const [totalCount, setTotalCount] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("")
const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all")
const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all")
@ -75,7 +76,7 @@ export function AppVersionsTab() {
setLoading(true)
setError(false)
try {
const res = await getAppVersions({ limit: PAGE_SIZE, offset })
const res = await getAppVersions({ limit: pageSize, offset })
setVersions(res.data.versions)
setTotalCount(res.data.total_count)
} catch (e) {
@ -87,7 +88,7 @@ export function AppVersionsTab() {
} finally {
setLoading(false)
}
}, [offset])
}, [offset, pageSize])
useEffect(() => {
void load()
@ -123,7 +124,7 @@ export function AppVersionsTab() {
const pageStart = totalCount === 0 ? 0 : offset + 1
const pageEnd = Math.min(offset + versions.length, totalCount)
const canPrev = offset > 0
const canNext = offset + PAGE_SIZE < totalCount
const canNext = offset + pageSize < totalCount
const handleCreated = (version: AppVersion) => {
if (offset === 0) {
@ -155,12 +156,7 @@ export function AppVersionsTab() {
</p>
<h2 className="text-lg font-bold text-grayScale-900">App version control</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage Android and iOS release metadata for in-app update prompts. Versions are loaded
from{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
GET /admin/app-versions
</code>
.
Manage Android and iOS release metadata for in-app update prompts.
</p>
</div>
<div className="flex flex-wrap gap-2">
@ -191,7 +187,7 @@ export function AppVersionsTab() {
<TabletSmartphone className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Total (this page)</p>
<p className="text-xs font-medium text-grayScale-500">Total versions</p>
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
</div>
</CardContent>
@ -440,10 +436,34 @@ export function AppVersionsTab() {
)}
{!loading && !error && totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
<p className="text-xs text-grayScale-500">
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Showing {pageStart}{pageEnd} of {totalCount}
</p>
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
@ -451,7 +471,7 @@ export function AppVersionsTab() {
size="sm"
className="rounded-[6px]"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
@ -462,7 +482,7 @@ export function AppVersionsTab() {
size="sm"
className="rounded-[6px]"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + PAGE_SIZE)}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />

View File

@ -100,9 +100,8 @@ export function SubscriptionPlansTab() {
</p>
<h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage learner subscription plans from{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /subscription-plans</code>
. Create, edit, or remove packages for the learner checkout flow.
Manage learner subscription plans. Create, edit, or remove packages for the learner
checkout flow.
</p>
</div>
<div className="flex flex-wrap gap-2">

View File

@ -148,9 +148,8 @@ export function CreateAppVersionDialog({
New app version
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Publishes a release via{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /admin/app-versions</code>
. Learners on older builds will see update prompts based on these rules.
Publish a new app release. Learners on older builds will see update prompts based on
these rules.
</DialogDescription>
</DialogHeader>

View File

@ -128,8 +128,7 @@ export function CreateSubscriptionPlanDialog({
New subscription package
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Creates a plan via{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /subscription-plans</code>
Add a new subscription package for learners.
</DialogDescription>
</DialogHeader>

View File

@ -140,10 +140,7 @@ export function EditAppVersionDialog({
Edit app version
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Updates via{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
PUT /admin/app-versions/{version?.id ?? ":id"}
</code>
Update release rules and messaging for this version.
</DialogDescription>
</DialogHeader>

View File

@ -139,10 +139,7 @@ export function EditSubscriptionPlanDialog({
Edit subscription package
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Updates via{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
PUT /subscription-plans/{plan?.id ?? ":id"}
</code>
Update pricing, duration, and visibility for this plan.
</DialogDescription>
</DialogHeader>

View File

@ -20,6 +20,7 @@ import {
} from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner";
@ -413,7 +414,7 @@ export function TeamManagementPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -36,6 +36,7 @@ import {
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
@ -500,7 +501,7 @@ export function UserLogPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -16,6 +16,7 @@ import {
import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import type {
DeletionRequest,
DeletionState,
@ -580,7 +581,7 @@ export function DeletionRequestsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50, 100].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -9,6 +9,7 @@ import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { getDashboard } from "../../api/analytics.api"
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import type { DashboardUsers } from "../../types/analytics.types"
@ -689,7 +690,7 @@ export function UsersListPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -1066,7 +1066,8 @@ export interface QuestionOption {
}
export interface CreateQuestionRequest {
question_text: string
/** Omit for `DYNAMIC` — prompt belongs in `dynamic_payload.stimulus` */
question_text?: string
question_type: string
difficulty_level?: string
points?: number

View File

@ -9,6 +9,7 @@ export interface NotificationPayload {
export interface Notification {
id: string
recipient_id: number
receiver_type?: string
type: string
level: string
error_severity: string