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

View File

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

View File

@ -1,35 +1,125 @@
import http from "./http"; import http from "./http"
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types"; 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) => export const getNotifications = (limit = 10, offset = 0) =>
http.get<GetNotificationsResponse>("/notifications", { http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
params: { limit, offset }, ...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 = () => 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) => export const markAsRead = (id: string) =>
http.patch(`/notifications/${id}/read`); http.patch(`/notifications/${id}/read`)
export const markAsUnread = (id: string) => export const markAsUnread = (id: string) =>
http.patch(`/notifications/${id}/unread`); http.patch(`/notifications/${id}/unread`)
export const markAllRead = () => export const markAllRead = () =>
http.post("/notifications/mark-all-read"); http.post("/notifications/mark-all-read")
export const markAllUnread = () => 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 }) => 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) => export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, { http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); })
export const sendBulkPush = (formData: FormData) => export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, { http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); })

View File

@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
/** @deprecated use extractDefinitionMutationId */ /** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = 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[] { export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return [] if (!payload) return []
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
} }
if (typeof payload === "object" && payload !== null) { if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown> 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 (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 const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data) if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") { if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(data)
return single ? [single] : []
}
const single = normalizeTypeDefinitionFromApi(payload) const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : [] return single ? [single] : []
} }
return [] return []
} }
export async function getQuestionTypeDefinitions(params?: { export async function getQuestionTypeDefinitions(
include_system?: boolean params?: QuestionTypeDefinitionsListParams,
status?: string ): Promise<QuestionTypeDefinitionsListResult> {
limit?: number
offset?: number
}) {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params }) const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data 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 ChangeEvent,
type DragEvent, type DragEvent,
} from "react" } 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 { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api" import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Button } from "../ui/button"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea" import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon" import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage" import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicTableBuilder } from "./DynamicTableBuilder"
import { slotLabel } from "../../lib/schemaSlotLabel"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024
@ -28,13 +31,43 @@ export interface DynamicSchemaSlotRow {
required?: boolean required?: boolean
} }
function slotMediaMode(kind: string): "image" | "audio" | "text" { function slotMediaMode(
kind: string,
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
const u = kind.trim().toUpperCase() const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image" 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" 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 { function isHttpUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim()) return /^https?:\/\//i.test(s.trim())
} }
@ -137,7 +170,9 @@ function DynamicImageSlot({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2"> <div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label> <label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span> {slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div> </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="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div <div
@ -407,7 +442,9 @@ function DynamicAudioSlot({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2"> <div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label> <label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span> {slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div> </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="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]"> <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 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({ export function DynamicSchemaSlotField({
row, row,
value, value,
@ -544,24 +680,52 @@ export function DynamicSchemaSlotField({
disabled = false, disabled = false,
}: DynamicSchemaSlotFieldProps) { }: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind) const mode = slotMediaMode(row.kind)
const baseLabel = const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind) if (mode === "table") {
const slotLabel = `${baseLabel}${row.required ? " *" : ""}` return (
const slotMeta = `${row.id} · ${row.kind}` <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") { if (mode === "text") {
return ( return (
<div className="space-y-2"> <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">{fieldLabel}</label>
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<Textarea <Textarea
rows={3} rows={3}
value={value} value={value}
onChange={(e) => onChange(e.target.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" className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled} disabled={disabled}
/> />
@ -575,8 +739,20 @@ export function DynamicSchemaSlotField({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
slotLabel={slotLabel} slotLabel={fieldLabel}
slotMeta={slotMeta} slotMeta=""
/>
)
}
if (mode === "pdf") {
return (
<DynamicPdfSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
slotMeta=""
/> />
) )
} }
@ -586,8 +762,8 @@ export function DynamicSchemaSlotField({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
slotLabel={slotLabel} slotLabel={fieldLabel}
slotMeta={slotMeta} 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) setDefinitionsLoading(true)
;(async () => { ;(async () => {
try { try {
const rows = await getQuestionTypeDefinitions({ include_system: true }) const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : []) if (!cancelled) setTypeDefinitions(rows)
} catch { } catch {
if (!cancelled) setTypeDefinitions([]) if (!cancelled) setTypeDefinitions([])
} finally { } finally {
@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
{value.questionType === "DYNAMIC" && ( {value.questionType === "DYNAMIC" && (
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3"> <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"> <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 Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other fields accept text or structured values where noted.
slots: text or JSON.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <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 { useNavigate } from "react-router-dom"
import { import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
Bell, import { toast } from "sonner"
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon" import { SpinnerIcon } from "../ui/spinner-icon"
import { getNotificationById } from "../../api/notifications.api"
import { useNotifications } from "../../hooks/useNotifications" 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" 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({ function NotificationItem({
notification, notification,
onOpen,
onMarkRead, onMarkRead,
onMarkUnread, onMarkUnread,
}: { }: {
notification: Notification notification: Notification
onOpen: (notification: Notification) => void
onMarkRead: (id: string) => void onMarkRead: (id: string) => void
onMarkUnread: (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 const Icon = cfg.icon
return ( return (
@ -77,31 +35,26 @@ function NotificationItem({
className={cn( className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100", "group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
)} )}
onClick={() => { onClick={() => onOpen(notification)}
if (!notification.is_read) onMarkRead(notification.id)
}}
> >
{/* Unread dot */}
{!notification.is_read && ( {!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" /> <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 <span
className={cn( className={cn(
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg", "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)} /> <Icon className={cn("h-4 w-4", cfg.color)} />
</span> </span>
{/* Content */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className={cn( className={cn(
"text-sm leading-snug text-grayScale-900", "text-sm leading-snug text-grayScale-900",
!notification.is_read && "font-semibold" !notification.is_read && "font-semibold",
)} )}
> >
{getNotificationTitle(notification) || "Notification"} {getNotificationTitle(notification) || "Notification"}
@ -110,11 +63,10 @@ function NotificationItem({
{getNotificationMessage(notification) || "No preview text available."} {getNotificationMessage(notification) || "No preview text available."}
</p> </p>
<p className="mt-1 text-[11px] text-grayScale-600"> <p className="mt-1 text-[11px] text-grayScale-600">
{formatTimestamp(notification.timestamp)} {formatNotificationTimestamp(notification.timestamp)}
</p> </p>
</div> </div>
{/* Read / Unread toggle */}
<button <button
type="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" 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() { export function NotificationDropdown() {
const [open, setOpen] = useState(false) 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 containerRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
const { const {
@ -151,7 +108,40 @@ export function NotificationDropdown() {
markAllAsRead, markAllAsRead,
} = useNotifications() } = 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(() => { useEffect(() => {
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@ -165,89 +155,98 @@ export function NotificationDropdown() {
}, [open]) }, [open])
return ( return (
<div ref={containerRef} className="relative"> <>
{/* Bell button */} <div ref={containerRef} className="relative">
<button <button
type="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" className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications" aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white"> <span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount} {unreadCount > 99 ? "99+" : unreadCount}
</span> </span>
)} )}
</button> </button>
{/* Dropdown panel */} {open && (
{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">
<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"> <div className="flex items-center justify-between border-b px-4 py-3">
{/* Header */} <div className="flex items-center gap-2">
<div className="flex items-center justify-between border-b px-4 py-3"> <h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
<div className="flex items-center gap-2"> {unreadCount > 0 && (
<h3 className="text-sm font-semibold text-grayScale-800"> <Badge variant="default" className="px-1.5 py-0 text-[10px]">
Notifications {unreadCount}
</h3> </Badge>
)}
</div>
{unreadCount > 0 && ( {unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]"> <button
{unreadCount} type="button"
</Badge> className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)} )}
</div> </div>
{unreadCount > 0 && (
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onOpen={handleOpenNotification}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
<div className="border-t px-4 py-2.5">
<button <button
type="button" type="button"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700" className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={markAllAsRead} onClick={() => {
setOpen(false)
navigate("/notifications")
}}
> >
<CheckCheck className="h-3.5 w-3.5" /> View all notifications
Mark all read
</button> </button>
)} </div>
</div> </div>
)}
</div>
{/* Body */} <NotificationDetailDialog
<div className="max-h-[480px] overflow-y-auto"> open={detailOpen}
{loading ? ( onOpenChange={setDetailOpen}
<div className="flex items-center justify-center py-12"> notification={selectedNotification}
<SpinnerIcon className="h-6 w-6" /> loading={detailLoading}
</div> error={detailError}
) : notifications.length === 0 ? ( onRetry={
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400"> selectedNotificationId
<BellOff className="h-8 w-8" /> ? () => void loadNotificationDetail(selectedNotificationId, false)
<p className="text-sm">No notifications</p> : undefined
</div> }
) : ( />
<div className="p-1"> </>
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
>
View all notifications
</button>
</div>
</div>
)}
</div>
) )
} }

View File

@ -3,12 +3,13 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
import type { Notification } from "../types/notification.types" import type { Notification } from "../types/notification.types"
const MAX_DROPDOWN = 5 const MAX_DROPDOWN = 5
const RECONNECT_MS = 5000
function getWsUrl() { function getWsUrl() {
const base = import.meta.env.VITE_API_BASE_URL as string const base = import.meta.env.VITE_API_BASE_URL as string
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws") const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
const token = localStorage.getItem("access_token") ?? "" const token = localStorage.getItem("access_token") ?? ""
return `${wsBase}/ws/connect?token=${token}` return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
} }
export function useNotifications() { export function useNotifications() {
@ -18,6 +19,8 @@ export function useNotifications() {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true) const mountedRef = useRef(true)
const intentionalCloseRef = useRef(false)
const connectAttemptRef = useRef(0)
const dispatchUpdate = () => { const dispatchUpdate = () => {
window.dispatchEvent(new Event("notifications-updated")) window.dispatchEvent(new Event("notifications-updated"))
@ -40,11 +43,37 @@ export function useNotifications() {
} }
}, []) }, [])
const connectWs = useCallback(() => { const clearReconnectTimer = useCallback(() => {
if (wsRef.current) { if (reconnectTimer.current) {
wsRef.current.close() 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()) const ws = new WebSocket(getWsUrl())
wsRef.current = ws wsRef.current = ws
@ -78,47 +107,45 @@ export function useNotifications() {
} }
} }
ws.onerror = () => {
ws.close()
}
ws.onclose = () => { 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(() => { reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs() if (mountedRef.current) connectWs()
}, 5000) }, RECONNECT_MS)
} }
}, []) }, [clearReconnectTimer, disconnectWs])
useEffect(() => { useEffect(() => {
mountedRef.current = true mountedRef.current = true
intentionalCloseRef.current = false
fetchData() fetchData()
connectWs() connectWs()
return () => { return () => {
mountedRef.current = false mountedRef.current = false
wsRef.current?.close() disconnectWs(true)
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
} }
}, [fetchData, connectWs]) }, [fetchData, connectWs, disconnectWs])
const markOneRead = useCallback(async (id: string) => { const markOneRead = useCallback(async (id: string) => {
setNotifications((prev) => 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)) setUnreadCount((prev) => Math.max(0, prev - 1))
dispatchUpdate() dispatchUpdate()
try { try {
await markAsRead(id) await markAsRead(id)
} catch { } catch {
// revert on failure
await fetchData() await fetchData()
} }
}, [fetchData]) }, [fetchData])
const markOneUnread = useCallback(async (id: string) => { const markOneUnread = useCallback(async (id: string) => {
setNotifications((prev) => 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) setUnreadCount((prev) => prev + 1)
dispatchUpdate() 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 { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types" import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload" 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 { export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0 return def.stimulus_schema.length > 0 || def.response_schema.length > 0
} }
@ -10,11 +19,55 @@ export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition, def: QuestionTypeDefinition,
): Record<string, string> { ): Record<string, string> {
const o: Record<string, string> = {} const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = "" for (const r of def.stimulus_schema) {
for (const r of def.response_schema) o[`response:${r.id}`] = "" o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
return o 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. * System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown). * Returns null when the payload must be DYNAMIC (schema-driven or unknown).
@ -41,6 +94,24 @@ export interface LearnEnglishDefinitionQuestionInput {
sampleAnswerVoiceUrl?: string 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( export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition, def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput, q: LearnEnglishDefinitionQuestionInput,
@ -51,13 +122,17 @@ export function buildCreateQuestionFromDefinition(
const question_text = q.questionText.trim() const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) { if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({ const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })), stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_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 { return {
question_text,
question_type: "DYNAMIC", question_type: "DYNAMIC",
question_type_definition_id: def.id, question_type_definition_id: def.id,
difficulty_level: difficulty, 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 { return {
question_text,
question_type: "DYNAMIC", question_type: "DYNAMIC",
question_type_definition_id: def.id, question_type_definition_id: def.id,
difficulty_level: difficulty, difficulty_level: difficulty,
@ -136,24 +209,36 @@ export function validateDefinitionQuestion(
index1Based: number, index1Based: number,
): string | null { ): string | null {
const n = index1Based const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) { 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) { for (const row of def.stimulus_schema) {
if (!row.required) continue if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim() const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v) if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).` return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
} }
for (const row of def.response_schema) { for (const row of def.response_schema) {
if (!row.required) continue if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim() const v = fieldValues[`response:${row.id}`]?.trim()
if (!v) if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).` return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
} }
return null return null
} }
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def) const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") { if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim()) 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 type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { import {
buildCreateQuestionFromDefinition, buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion, validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput, type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion" } from "./learnEnglishDefinitionQuestion"
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[], questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[], definitions: QuestionTypeDefinition[],
): string | null { ): 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 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++) { for (let i = 0; i < filled.length; i++) {
const q = filled[i] const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) { 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 let displayOrder = 0
for (const q of toCreate) { for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId) 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 React, { useEffect, useState } from "react";
import { import {
Bell,
Eye, Eye,
EyeOff, EyeOff,
Globe, Globe,
@ -23,7 +22,6 @@ import {
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select"; import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon"; import { SpinnerIcon } from "../components/ui/spinner-icon";
import { changeTeamMemberPassword } from "../api/team.api"; import { changeTeamMemberPassword } from "../api/team.api";
@ -41,7 +39,6 @@ type SettingsTab =
| "app-versions" | "app-versions"
| "profile" | "profile"
| "security" | "security"
| "notifications"
| "appearance"; | "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [ 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: "app-versions", label: "App versions", icon: Smartphone },
{ id: "profile", label: "Profile", icon: User }, { id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield }, { id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Appearance", icon: Palette }, { 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 }) { function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name); const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name); const [lastName, setLastName] = useState(profile.last_name);
@ -623,7 +564,6 @@ export function SettingsPage() {
{activeTab === "app-versions" && <AppVersionsTab />} {activeTab === "app-versions" && <AppVersionsTab />}
{activeTab === "profile" && <ProfileTab profile={profile} />} {activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab memberId={profile.id} />} {activeTab === "security" && <SecurityTab memberId={profile.id} />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />} {activeTab === "appearance" && <AppearanceTab />}
</main> </main>
</div> </div>

View File

@ -373,30 +373,34 @@ export function AddNewPracticePage() {
}) })
: undefined; : undefined;
const qRes = await createQuestion({ const qRes = await createQuestion(
question_text: q.questionText, q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
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: "DYNAMIC",
question_type_definition_id: q.questionTypeDefinitionId, question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload, 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,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
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; const questionId = qRes.data?.data?.id;
if (questionId) { if (questionId) {

View File

@ -150,7 +150,7 @@ export function AddPracticeFlow() {
setDefinitionsLoading(true); setDefinitionsLoading(true);
setDefinitionsError(null); setDefinitionsError(null);
try { try {
const list = await getQuestionTypeDefinitions({ const { definitions: list } = await getQuestionTypeDefinitions({
include_system: true, include_system: true,
status: "ACTIVE", status: "ACTIVE",
}); });
@ -216,9 +216,7 @@ export function AddPracticeFlow() {
return; return;
} }
const persona = personaFromId(selectedPersona, personas); const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions const mappedQuestions = formData.questions.map((q) => ({
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
questionText: String(q.text ?? "").trim(), questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId), questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) }, dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
onCancel={() => navigate(backPath)} onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice} isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay} lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/> />
); );
case 2: case 2:
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
</Button> </Button>
</div> </div>
<p className="text-grayScale-400 text-base"> <p className="text-grayScale-400 text-base">
Create a practice: question types from{" "} Create a practice with story details, a persona, and questions from your question type library.
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
</p> </p>
{lessonId ? ( {lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950"> <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 let cancelled = false
;(async () => { ;(async () => {
try { try {
const rows = await getQuestionTypeDefinitions({ include_system: true }) const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : []) if (!cancelled) setTypeDefinitions(rows)
} catch { } catch {
if (!cancelled) setTypeDefinitions([]) if (!cancelled) setTypeDefinitions([])
} }
@ -268,7 +268,7 @@ export function AddQuestionPage() {
return return
} }
} catch { } 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 return
} }
} }
@ -419,7 +419,7 @@ export function AddQuestionPage() {
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-grayScale-600"> <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> </label>
<Textarea <Textarea
value={formData.dynamicPayloadJson} value={formData.dynamicPayloadJson}

View File

@ -28,6 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string } 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </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"> <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> <DialogTitle>Edit module</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, sort order, and icon (upload or URL). Saved with{" "} Update name, sort order, and icon (upload or URL).
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"> <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" } from "../../api/courses.api"
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types" import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

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

View File

@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
<div> <div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p> <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"> <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> </p>
</div> </div>
</CardContent> </CardContent>
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Create module</DialogTitle> <DialogTitle>Create module</DialogTitle>
<DialogDescription> <DialogDescription>
Add a module to this level. This will call `POST /course-management/modules`. Add a module to this level.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Update module</DialogTitle> <DialogTitle>Update module</DialogTitle>
<DialogDescription> <DialogDescription>
Update this module using `PUT /course-management/modules/:moduleId`. Update this module&apos;s name, order, and settings.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

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

View File

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

View File

@ -364,12 +364,6 @@ export function LessonPracticesPage() {
total={practices.length} 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>
)} )}
</div> </div>

View File

@ -668,15 +668,7 @@ export function ModuleDetailPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Edit lesson</DialogTitle> <DialogTitle>Edit lesson</DialogTitle>
<DialogDescription> <DialogDescription>
Update details. Video and thumbnail files use{" "} Update lesson details. Uploaded video and thumbnail files are stored automatically.
<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>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">

View File

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

View File

@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types" 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 QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
@ -84,7 +85,7 @@ export function PracticeQuestionsPage() {
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({}) const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none") const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc") const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize] = useState(10) const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0) const [totalQuestions, setTotalQuestions] = useState(0)
@ -736,29 +737,56 @@ export function PracticeQuestionsPage() {
))} ))}
</div> </div>
))} ))}
{totalQuestions > pageSize && ( {totalQuestions > 0 && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3"> <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">
<p className="text-sm text-grayScale-500"> <div className="flex flex-wrap items-center gap-2">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} <span>
</p> Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
<div className="flex items-center gap-2"> total)
<Button </span>
variant="outline" <span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
size="sm" <span className="flex items-center gap-2">
disabled={currentPage <= 1} Rows per page
onClick={() => void fetchQuestions(currentPage - 1)} <div className="relative">
> <select
Previous value={pageSize}
</Button> onChange={(e) => {
<Button setPageSize(Number(e.target.value))
variant="outline" setCurrentPage(1)
size="sm" void fetchQuestions(1)
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)} }}
onClick={() => void fetchQuestions(currentPage + 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"
> >
Next {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
</Button> <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>
{totalQuestions > pageSize ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
Add New Course Add New Course
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a course via{" "} Add a new course to this program. Use an image URL or upload a file for the
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600"> thumbnail.
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>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </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"> <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> <DialogTitle>Edit course</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, sort order, and thumbnail. Saved with{" "} Update name, sort order, and thumbnail.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"> <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 { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom" 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 { toast } from "sonner"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -15,6 +26,7 @@ import {
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { QuestionTypeCard } from "./components/QuestionTypeCard" import { QuestionTypeCard } from "./components/QuestionTypeCard"
import { import {
deleteQuestionTypeDefinition, deleteQuestionTypeDefinition,
@ -22,6 +34,23 @@ import {
} from "../../api/questionTypeDefinitions.api" } from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types" 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() { export function QuestionTypeLibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@ -30,24 +59,48 @@ export function QuestionTypeLibraryPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([]) const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
const [query, setQuery] = useState("") 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 [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
const [deleteSubmitting, setDeleteSubmitting] = useState(false) const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const hasActiveFilters =
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const rows = await getQuestionTypeDefinitions({ include_system: true }) const isSystemScope = scopeFilter === "system"
setDefinitions(Array.isArray(rows) ? rows : []) 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) { } catch (e) {
console.error(e) console.error(e)
toast.error("Failed to load question type definitions") toast.error("Failed to load question type definitions")
setDefinitions([]) setDefinitions([])
setTotalCount(0)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [offset, pageSize, scopeFilter, statusFilter])
useEffect(() => { useEffect(() => {
void load() void load()
@ -67,18 +120,36 @@ export function QuestionTypeLibraryPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (!q) return definitions
return definitions.filter((d) => { 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 name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase() const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q) 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) => { const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) { if (row.is_system) {
@ -124,9 +195,7 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1> <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"> <p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "} Reusable templates that define how practice and assessment questions are structured and answered.
<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.
</p> </p>
</div> </div>
<Link to="/new-content/question-types/create"> <Link to="/new-content/question-types/create">
@ -138,65 +207,240 @@ export function QuestionTypeLibraryPage() {
</div> </div>
</div> </div>
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6"> <Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4"> <CardHeader className="border-b border-grayScale-100 px-6 py-5">
<div className="relative flex-1"> <div className="flex flex-wrap items-center justify-between gap-3">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" /> <div className="flex items-center gap-3">
<Input <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm" <Layers className="h-5 w-5" aria-hidden />
placeholder="Search by display name, key, or id…" </div>
value={query} <div>
onChange={(e) => setQuery(e.target.value)} <CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
/> <p className="text-xs text-grayScale-500 mt-0.5">
</div> Browse and filter templates from the question type catalog
</div> </p>
</div>
<div className="flex items-center gap-3 flex-wrap"> </div>
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span> <Button
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
<button
key={tab}
type="button" type="button"
onClick={() => setActiveTab(tab)} variant="outline"
className={cn( size="sm"
"h-10 px-4 rounded-full text-[13px] font-medium transition-all", className="rounded-[8px] border-grayScale-200"
activeTab === tab disabled={loading}
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20" onClick={() => void load()}
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
> >
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"} <RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
</button> Refresh
))} </Button>
</div> </div>
</Card> </CardHeader>
{loading ? ( <CardContent className="p-0">
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p> <div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
) : filtered.length === 0 ? ( <div className="relative">
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl"> <Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p> <Input
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p> className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
</Card> placeholder="Search by display name, key, or id on this page…"
) : ( value={query}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> onChange={(e) => setQuery(e.target.value)}
{filtered.map((d) => ( />
<QuestionTypeCard {query ? (
key={d.id} <button
id={d.id} type="button"
definitionKey={d.key} onClick={() => setQuery("")}
display_name={d.display_name} 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"
status={d.status} aria-label="Clear search"
is_system={d.is_system} >
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0} <X className="h-4 w-4" />
responseKindsCount={d.response_component_kinds?.length ?? 0} </button>
deleteDisabled={!!d.is_system} ) : null}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)} </div>
onDelete={() => openDeleteConfirm(d)}
/> <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> <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>
<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 ? (
<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 ? (
<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>
) : (
<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}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</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}> <Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md"> <DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
@ -244,3 +488,32 @@ export function QuestionTypeLibraryPage() {
</div> </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 { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types" import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD" 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

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

View File

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

View File

@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
/> />
</div> </div>
<p className="text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
This calls <span className="font-mono">POST /question-sets</span> with{" "} Creates a practice question set for this course, module, or lesson.
<span className="font-mono">set_type: PRACTICE</span>.
</p> </p>
<Button type="button" onClick={handleStep1} disabled={saving}> <Button type="button" onClick={handleStep1} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null} {saving ? <SpinnerIcon className="h-4 w-4" /> : null}
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 2 && ( {canUseWizard && step === 2 && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> add Add one or more audio questions to question set #{questionSetId}.
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
<span className="font-mono">POST /questions</span>.
</p> </p>
{questionRows.map((row, idx) => ( {questionRows.map((row, idx) => (
<div <div
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 3 && ( {canUseWizard && step === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Link each question to the set with a display order using{" "} Confirm the order of questions in the set.
<span className="font-mono">POST /question-sets/&#123;id&#125;/questions</span>.
</p> </p>
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3"> <ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
{createdQuestionIds.map((qid, i) => ( {createdQuestionIds.map((qid, i) => (
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 4 && parent && ( {canUseWizard && step === 4 && parent && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Parent:{" "} Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
<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>
</p> </p>
<div> <div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> <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 { Badge } from "../../../components/ui/badge"
import { Card } from "../../../components/ui/card" import { Card } from "../../../components/ui/card"
import { Button } from "../../../components/ui/button" import { Button } from "../../../components/ui/button"
@ -42,7 +42,12 @@ export function QuestionTypeCard({
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
System System
</Badge> </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> </div>
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p> <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. */ /** Lesson-linked practice: no title, story description, or story image on step 1. */
isLessonPractice?: boolean; isLessonPractice?: boolean;
lessonTitle?: string | null; lessonTitle?: string | null;
parentSummary?: string | null;
} }
/** /**
@ -27,6 +28,7 @@ export function ContextStep({
onCancel, onCancel,
isLessonPractice = false, isLessonPractice = false,
lessonTitle = null, lessonTitle = null,
parentSummary = null,
}: ContextStepProps) { }: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null); const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false); const [uploadingStory, setUploadingStory] = useState(false);
@ -62,16 +64,15 @@ export function ContextStep({
<p className="text-grayScale-600 text-base mt-3"> <p className="text-grayScale-600 text-base mt-3">
{isLessonPractice ? ( {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"> <span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"} {lessonTitle?.trim() || "the selected lesson"}
</span> </span>
. Set optional quick tips and question order below. .
</> </>
) : ( ) : (
<> <>
Title, story, optional image, shuffle, and quick tips match the create Story fields and question set options used when saving the practice.
practice and question set APIs.
</> </>
)} )}
</p> </p>
@ -90,6 +91,16 @@ export function ContextStep({
</div> </div>
<div className="space-y-8 p-10"> <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 ? ( {!isLessonPractice ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
@ -132,7 +143,7 @@ export function ContextStep({
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, tips: e.target.value }) 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" className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000} maxLength={1000}
/> />

View File

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

View File

@ -10,6 +10,8 @@ import {
emptyDynamicFieldValuesForDefinition, emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition, legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion"; } from "../../../../lib/learnEnglishDefinitionQuestion";
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
import { toast } from "sonner";
function defaultMcqOptions() { function defaultMcqOptions() {
return [ return [
@ -98,9 +100,9 @@ export function QuestionsStep({
return ( return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3"> <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"> <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 ( <span className="font-medium text-grayScale-800">Image, audio, and PDF</span> use upload or URL.{" "}
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code> <span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
). Others: URL, text, or JSON. prep-time slots use seconds. Other slots use text or structured JSON where noted.
</p> </p>
{def.stimulus_schema.length > 0 ? ( {def.stimulus_schema.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
@ -307,11 +309,8 @@ export function QuestionsStep({
<div className="space-y-1 px-2"> <div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2> <h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg"> <p className="text-grayScale-400 text-lg">
Question types are loaded from{" "} Choose a question type for each item, then fill in the fields that type requires. Questions are saved
<code className="rounded bg-grayScale-100 px-1 text-sm"> when you publish or save the practice.
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
</p> </p>
</div> </div>
@ -403,21 +402,28 @@ export function QuestionsStep({
) : null} ) : null}
</div> </div>
<div className="space-y-3"> {def && !definitionUsesDynamicPayload(def) ? (
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700"> <div className="space-y-3">
Question text <label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
</label> Question text
<Input </label>
value={q.text} <Input
onChange={(e) => { value={q.text}
const newQuestions = [...formData.questions]; onChange={(e) => {
newQuestions[i].text = e.target.value; const newQuestions = [...formData.questions];
setFormData({ ...formData, questions: newQuestions }); newQuestions[i].text = e.target.value;
}} setFormData({ ...formData, questions: newQuestions });
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700" }}
placeholder="Question prompt for learners" className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
/> placeholder="Question prompt for learners"
</div> />
</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} {def ? renderTypeSpecificFields(q, i, def) : null}
</div> </div>
@ -451,7 +457,30 @@ export function QuestionsStep({
</Button> </Button>
<Button <Button
type="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} disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50" 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"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2> <h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
<p className="text-grayScale-500 font-medium mt-1"> <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 Set the reusable key, display name, and status. On the next step you will choose how questions are
component types from the live catalog ( presented and how learners answer.
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
</p> </p>
</div> </div>

View File

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

View File

@ -11,30 +11,54 @@ import {
validateQuestionTypeDefinition, validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api" } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types" 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 { interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload draft: QuestionTypeDefinitionCreatePayload
onBack: () => void onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */ /** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null editDefinitionId?: number | null
isSystem?: boolean
} }
export function QuestionTypeReviewPublishStep({ export function QuestionTypeReviewPublishStep({
draft, draft,
onBack, onBack,
editDefinitionId, editDefinitionId,
isSystem,
}: QuestionTypeReviewPublishStepProps) { }: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0 const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft) const payload = buildCreatePayload(draft)
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
const submit = async (status: "ACTIVE" | "INACTIVE") => { 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 } const body = { ...payload, status }
setSubmitting(true) setSubmitting(true)
try { 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) { if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body) const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId const id = extractDefinitionMutationId(res) ?? editDefinitionId
@ -45,14 +69,6 @@ export function QuestionTypeReviewPublishStep({
return 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 res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res) const id = extractDefinitionMutationId(res)
if (id == null) { if (id == null) {
@ -81,30 +97,30 @@ export function QuestionTypeReviewPublishStep({
<div className="space-y-8 pb-32"> <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"> <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"> <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"> <p className="text-grayScale-500 font-medium mt-1">
{isEdit ? ( {isEdit
<> ? "Confirm your changes and save. The definition key cannot be changed."
Confirm changes, then update via{" "} : "Confirm your definition, then save it for use when authoring practice questions."}
<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>.
</>
)}
</p> </p>
</div> </div>
<div className="p-10 space-y-6"> <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"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt> <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>
<div> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt> <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> <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> <dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div> </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> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt> <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> <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> <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> <dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div> </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> </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"> <div className="flex flex-wrap gap-3 pt-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-11" className="h-11"
disabled={submitting} disabled={submitting || runtime == null}
onClick={() => void submit("INACTIVE")} 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>
<Button <Button
type="button" type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white" className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting} disabled={submitting || runtime == null}
onClick={() => void submit("ACTIVE")} 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> </Button>
</div> </div>
</div> </div>
@ -173,3 +202,35 @@ export function QuestionTypeReviewPublishStep({
</div> </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 { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "../../../../components/ui/button" import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card" import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api" import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types" import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation" import {
buildCreatePayload,
buildValidateKindsPayload,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
interface QuestionTypeValidatePreviewStepProps { interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload draft: QuestionTypeDefinitionCreatePayload
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
const payload = buildCreatePayload(draft) const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2) const json = JSON.stringify(payload, null, 2)
const runValidate = async () => { const runValidate = useCallback(async () => {
setValidating(true) setValidating(true)
setServerOk(null) setServerOk(null)
setServerDetail(null) setServerDetail(null)
try { try {
const res = await validateQuestionTypeDefinition(payload) const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!res.valid) { if (!res.valid) {
setServerOk(false) setServerOk(false)
const detail = res.error || res.message || "Validation failed" const detail = res.error || res.message || "Validation failed"
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
return return
} }
setServerOk(true) setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.") setServerDetail(res.message || "Component kinds are valid.")
toast.success(res.message || "Question type definition is valid") toast.success(res.message || "Definition kinds validated")
} finally { } finally {
setValidating(false) 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 ( return (
<div className="space-y-8 pb-32"> <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"> <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"> <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"> <p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "} We check that your stimulus and response selections are valid before you continue. You must pass
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code> validation before review.
. 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).
</p> </p>
</div> </div>
<div className="p-10 space-y-6"> <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"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={runValidate} onClick={() => void runValidate()}
disabled={validating} disabled={validating}
className="h-10" className="h-10"
> >
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
Validating Validating
</> </>
) : ( ) : (
"Validate with server" "Re-validate"
)} )}
</Button> </Button>
{serverOk === true ? ( {serverOk === true ? (
@ -83,12 +104,49 @@ export function QuestionTypeValidatePreviewStep({
{serverDetail} {serverDetail}
</span> </span>
) : null} ) : 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> </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"> <div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p> <p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
<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"> 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} {json}
</pre> </pre>
</div> </div>
@ -106,10 +164,11 @@ export function QuestionTypeValidatePreviewStep({
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={onNext} onClick={handleNext}
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" 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" /> <ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>

View File

@ -4,6 +4,11 @@ import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea" import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select" import { Select } from "../../../../components/ui/select"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types" import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
type Side = "stimulus" | "response" type Side = "stimulus" | "response"
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
return { return {
id: "", id: "",
kind: first, kind: first,
label: "", label: first ? defaultLabelForKind(first) : "",
required: true, required: true,
config: undefined, config: undefined,
} }
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : [] allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => { 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) onChange(next)
} }
const kindPresentation = (kind: string) =>
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
const commitConfigString = (index: number, raw: string) => { const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim() const trimmed = raw.trim()
if (!trimmed) { if (!trimmed) {
@ -73,9 +92,8 @@ export function SchemaBuilderSection({
<div> <div>
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3> <h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5"> <p className="text-[13px] text-grayScale-500 mt-0.5">
Each row defines one element in the{" "} Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON authors see when creating questions.
config).
</p> </p>
</div> </div>
<Button <Button
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
> >
{kindOptions.map((k) => ( {kindOptions.map((k) => (
<option key={k} value={k}> <option key={k} value={k}>
{k} {kindPresentation(k).label} ({k})
</option> </option>
))} ))}
</Select> </Select>
@ -154,11 +172,13 @@ export function SchemaBuilderSection({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5"> <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 <Input
value={row.label ?? ""} value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })} onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label" placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white" className="h-10 bg-white"
/> />
</div> </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, Volume2,
} from "lucide-react" } 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> = { const STIMULUS_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question Text", QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
PREP_TIME: "Prep Time", PREP_TIME: defaultLabelForKind("PREP_TIME"),
INSTRUCTION: "Instruction", INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
AUDIO_PROMPT: "Audio Prompt", AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
AUDIO_CLIP: "Audio Clip", TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
TEXT_PASSAGE: "Text Passage", IMAGE: defaultLabelForKind("IMAGE"),
IMAGE: "Image", MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
CHART: "Chart", SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
MATCHING_INPUTS: "Matching Inputs", TABLE: defaultLabelForKind("TABLE"),
SELECT_MISSING_WORDS: "Select Missing Words", PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
TABLE: "Table",
FLOW_CHART: "Flow Chart",
} }
const RESPONSE_LABELS: Record<string, string> = { const RESPONSE_LABELS: Record<string, string> = {
AUDIO_RESPONSE: "Audio Response", AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
TEXT_INPUT: "Text Input", TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
SHORT_ANSWER: "Short Answer", SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
MULTIPLE_CHOICE: "Multiple Choice", MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
OPTION: "Options", OPTION: defaultLabelForKind("OPTION"),
ANSWER_TIMER: "Answer Timer", ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
SELECT_MISSING_WORDS: "Select Missing Words", SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
PDF_UPLOAD: "PDF Upload", PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
MATCHING_ANSWER: "Matching Answer", MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
LABEL_SELECTION: "Label Selection", LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
SEQUENCE_ORDER: "Sequence Order", SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
} }
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */ /** 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, AUDIO_CLIP: Volume2,
TEXT_PASSAGE: FileText, TEXT_PASSAGE: FileText,
IMAGE: ImageIcon, IMAGE: ImageIcon,
CHART: BarChart3,
MATCHING_INPUTS: Link2, MATCHING_INPUTS: Link2,
SELECT_MISSING_WORDS: ListTodo, SELECT_MISSING_WORDS: ListTodo,
TABLE: TableIcon, TABLE: TableIcon,
FLOW_CHART: GitBranch, PDF_ATTACHMENT: FileUp,
} }
const RESPONSE_ICONS: Record<string, LucideIcon> = { const RESPONSE_ICONS: Record<string, LucideIcon> = {
@ -82,13 +82,6 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
const DEFAULT_ICON = FileText 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 } { export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
return { return {
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind), label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),

View File

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

View File

@ -3,6 +3,7 @@ import type {
QuestionComponentCatalog, QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload, QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types" } from "../../../types/questionTypeDefinition.types"
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
export type FieldErrorMap = Record<string, string> export type FieldErrorMap = Record<string, string>
@ -41,6 +42,18 @@ export function validateDefinitionKinds(
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind." 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) { if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds) const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds) const rCat = new Set(catalog.response_component_kinds)
@ -87,13 +100,18 @@ export function validateDefinitionSchemas(
const allowedSet = new Set(allowed) const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => { 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)) { 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)) { 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)) 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( export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload, draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload { ): QuestionTypeDefinitionCreatePayload {
@ -133,14 +187,14 @@ export function buildCreatePayload(
...r, ...r,
id: r.id.trim(), id: r.id.trim(),
kind: r.kind.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, config: r.config && Object.keys(r.config).length ? r.config : undefined,
})) }))
const response_schema = draft.response_schema.map((r) => ({ const response_schema = draft.response_schema.map((r) => ({
...r, ...r,
id: r.id.trim(), id: r.id.trim(),
kind: r.kind.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, config: r.config && Object.keys(r.config).length ? r.config : undefined,
})) }))

View File

@ -41,6 +41,7 @@ import {
DialogDescription, DialogDescription,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { import {
getIssues, 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

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

View File

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

View File

@ -3,17 +3,9 @@ import {
Bell, Bell,
BellOff, BellOff,
AlertTriangle, AlertTriangle,
Info,
AlertCircle,
CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Megaphone, Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen, MailOpen,
Mail, Mail,
CheckCheck, CheckCheck,
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
getNotificationById,
getNotifications, getNotifications,
getUnreadCount, getUnreadCount,
markAsRead, markAsRead,
@ -63,6 +56,14 @@ import {
sendBulkEmail, sendBulkEmail,
sendBulkPush, sendBulkPush,
} from "../../api/notifications.api" } 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 { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api" import { getTeamMembers } from "../../api/team.api"
import { getUsers } from "../../api/users.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 { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types" import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner" import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
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(" ")
}
function digitsOnly(value: string, maxLength: number) { function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength) return value.replace(/\D/g, "").slice(0, maxLength)
@ -149,7 +87,7 @@ function NotificationItem({
onToggleRead: (id: string, currentlyRead: boolean) => void onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean 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 const Icon = config.icon
return ( return (
@ -190,7 +128,7 @@ function NotificationItem({
> >
{getNotificationTitle(notification)} {getNotificationTitle(notification)}
</span> </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} {notification.level}
</Badge> </Badge>
</div> </div>
@ -206,7 +144,7 @@ function NotificationItem({
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400"> <span className="text-xs text-grayScale-400">
{formatTimestamp(notification.timestamp)} {formatNotificationTimestamp(notification.timestamp)}
</span> </span>
<button <button
type="button" type="button"
@ -236,7 +174,7 @@ function NotificationItem({
{/* Meta row */} {/* Meta row */}
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] px-2 py-0"> <Badge variant="secondary" className="text-[10px] px-2 py-0">
{formatTypeLabel(notification.type)} {formatNotificationTypeLabel(notification.type)}
</Badge> </Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0"> <Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel} {notification.delivery_channel}
@ -265,12 +203,16 @@ export function NotificationsPage() {
const [totalCount, setTotalCount] = useState(0) const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0) const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set()) const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false) const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null) const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all") const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all") const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
@ -429,7 +371,7 @@ export function NotificationsPage() {
setError(false) setError(false)
try { try {
const [notifRes, unreadRes] = await Promise.all([ const [notifRes, unreadRes] = await Promise.all([
getNotifications(PAGE_SIZE, currentOffset), getNotifications(pageSize, currentOffset),
getUnreadCount(), getUnreadCount(),
]) ])
setNotifications(notifRes.data.notifications ?? []) setNotifications(notifRes.data.notifications ?? [])
@ -440,11 +382,11 @@ export function NotificationsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [pageSize])
useEffect(() => { useEffect(() => {
fetchData(offset) fetchData(offset)
}, [offset, fetchData]) }, [offset, pageSize, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => { const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id)) setTogglingIds((prev) => new Set(prev).add(id))
@ -495,10 +437,10 @@ export function NotificationsPage() {
} }
}, [totalCount]) }, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1 const currentPage = Math.floor(offset / pageSize) + 1
const startEntry = totalCount === 0 ? 0 : offset + 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 getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
@ -525,7 +467,7 @@ export function NotificationsPage() {
const haystack = [ const haystack = [
getNotificationTitle(n), getNotificationTitle(n),
getNotificationMessage(n), getNotificationMessage(n),
formatTypeLabel(n.type), formatNotificationTypeLabel(n.type),
n.delivery_channel, n.delivery_channel,
n.level, n.level,
] ]
@ -537,9 +479,42 @@ export function NotificationsPage() {
return true return true
}) })
const handleOpenDetail = (notification: Notification) => { const loadNotificationDetail = useCallback(async (id: string) => {
setSelectedNotification(notification) setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true) 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 ( 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" 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"> <span className="truncate">
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)} {typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
</span> </span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" /> <ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button> </Button>
@ -766,7 +741,7 @@ export function NotificationsPage() {
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem> <DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => ( {Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}> <DropdownMenuRadioItem key={t} value={t}>
{formatTypeLabel(t)} {formatNotificationTypeLabel(t)}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@ -830,7 +805,7 @@ export function NotificationsPage() {
</TableRow> </TableRow>
) : ( ) : (
filteredNotifications.map((n) => { 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 Icon = config.icon
const isToggling = togglingIds.has(n.id) const isToggling = togglingIds.has(n.id)
return ( return (
@ -854,7 +829,7 @@ export function NotificationsPage() {
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<span className="text-xs font-medium text-grayScale-600"> <span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)} {formatNotificationTypeLabel(n.type)}
</span> </span>
</div> </div>
</TableCell> </TableCell>
@ -880,7 +855,7 @@ export function NotificationsPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={getLevelBadge(n.level)} variant={getNotificationLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide" className="text-[10px] uppercase tracking-wide"
> >
{n.is_read ? "Read" : "Unread"} {n.is_read ? "Read" : "Unread"}
@ -888,7 +863,7 @@ export function NotificationsPage() {
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell"> <TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400"> <span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)} {formatNotificationTimestamp(n.timestamp)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@ -941,18 +916,25 @@ export function NotificationsPage() {
<span className="border-l pl-4">Rows per page</span> <span className="border-l pl-4">Rows per page</span>
<div className="relative"> <div className="relative">
<select <select
value={PAGE_SIZE} value={pageSize}
disabled 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" 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> </select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" /> <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> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))} onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
disabled={currentPage <= 1} disabled={currentPage <= 1}
className={cn( className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500", "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 <button
key={n} key={n}
type="button" type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)} onClick={() => setOffset((n - 1) * pageSize)}
className={cn( className={cn(
"h-8 w-8 rounded-md border text-sm font-medium", "h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage n === currentPage
@ -983,7 +965,7 @@ export function NotificationsPage() {
), ),
)} )}
<button <button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)} onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
className={cn( className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500", "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 */} <NotificationDetailDialog
{selectedNotification && ( open={detailOpen}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}> onOpenChange={setDetailOpen}
<DialogContent> notification={selectedNotification}
<DialogHeader> loading={detailLoading}
<DialogTitle className="flex items-center gap-2"> error={detailError}
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600"> onRetry={
{(() => { selectedNotificationId
const Icon = ? () => void loadNotificationDetail(selectedNotificationId)
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon : undefined
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>
)}
{/* Bulk send dialog */} {/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}> <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>

View File

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

View File

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

View File

@ -41,7 +41,7 @@ import type {
PaymentStatus, PaymentStatus,
} from "../../types/payment.types" } 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 }[] = [ const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
{ value: "SUCCESS", label: "Success" }, { value: "SUCCESS", label: "Success" },
@ -168,8 +168,7 @@ export function PaymentsPage() {
</p> </p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1> <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"> <p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Browse checkout transactions from{" "} Browse and filter checkout transactions from Chapa, Arifpay, and other providers.
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
</p> </p>
</div> </div>
<Button <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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner"; 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -36,6 +36,7 @@ import {
DialogDescription, DialogDescription,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api"; import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types"; import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -16,6 +16,7 @@ import {
import { getDeletionRequests } from "../../api/users.api" import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api" import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import type { import type {
DeletionRequest, DeletionRequest,
DeletionState, 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -9,6 +9,7 @@ import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { getDashboard } from "../../api/analytics.api" import { getDashboard } from "../../api/analytics.api"
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api" import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import type { DashboardUsers } from "../../types/analytics.types" 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" 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}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

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

View File

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