Compare commits

..

5 Commits

21 changed files with 3122 additions and 1662 deletions

View File

@ -0,0 +1,303 @@
import http from "./http"
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
QuestionTypeDefinitionUpdatePayload,
QuestionTypeDefinitionValidatePayload,
ValidateQuestionTypeDefinitionResult,
} from "../types/questionTypeDefinition.types"
interface ApiEnvelope<T> {
message?: string
data?: T
/** Some routes use PascalCase in JSON */
Data?: T
success?: boolean
status_code?: number
error?: string
}
/**
* Reads the inner payload from a typical API envelope, or the raw body.
* Supports `data` / `Data` and list bodies where `res.data` is already an array
* (e.g. GET /questions/type-definitions `data: [ { ID, Key, … }, … ]`).
*/
export function unwrapApiPayload(res: { data?: unknown }): unknown {
const body = res.data
if (body === null || body === undefined) return undefined
if (Array.isArray(body)) return body
if (typeof body !== "object") return body
const o = body as Record<string, unknown>
if ("data" in o || "Data" in o) {
const inner = o.data ?? o.Data
return inner
}
return body
}
function fromStringArray(arr: unknown): string[] {
return Array.isArray(arr)
? arr.filter((k): k is string => typeof k === "string" && k.length > 0)
: []
}
function sortUniqueStrings(values: string[]): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b))
}
const emptyCatalog = (): QuestionComponentCatalog => ({
stimulus_component_kinds: [],
response_component_kinds: [],
})
/**
* Parse GET /questions/component-catalog body.
* Canonical shape: `data.stimulus_component_kinds` + `data.response_component_kinds`.
*/
export function parseComponentCatalog(payload: unknown): QuestionComponentCatalog {
if (!payload || typeof payload !== "object") return emptyCatalog()
const d = payload as Record<string, unknown>
if (
Array.isArray(d.stimulus_component_kinds) ||
Array.isArray(d.response_component_kinds)
) {
return {
stimulus_component_kinds: sortUniqueStrings(fromStringArray(d.stimulus_component_kinds)),
response_component_kinds: sortUniqueStrings(fromStringArray(d.response_component_kinds)),
}
}
if (d.data !== undefined && d.data !== null && typeof d.data === "object") {
const inner = parseComponentCatalog(d.data)
if (
inner.stimulus_component_kinds.length > 0 ||
inner.response_component_kinds.length > 0
) {
return inner
}
}
const sk = fromStringArray(d.stimulus_kinds)
const rk = fromStringArray(d.response_kinds)
if (sk.length || rk.length) {
return {
stimulus_component_kinds: sortUniqueStrings(sk),
response_component_kinds: sortUniqueStrings(rk),
}
}
const mergedFlat = sortUniqueStrings([
...fromStringArray(d.kinds),
...fromStringArray(d.codes),
...fromStringArray(d.component_kinds),
])
if (mergedFlat.length) {
return {
stimulus_component_kinds: mergedFlat,
response_component_kinds: [...mergedFlat],
}
}
if (Array.isArray(payload)) {
const merged = sortUniqueStrings(fromStringArray(payload))
return {
stimulus_component_kinds: merged,
response_component_kinds: [...merged],
}
}
return emptyCatalog()
}
export async function getQuestionComponentCatalog(): Promise<QuestionComponentCatalog> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/component-catalog")
const raw = unwrapApiPayload(res) ?? res.data
return parseComponentCatalog(raw)
}
function parseInnerValidFlag(inner: unknown): boolean | undefined {
if (inner == null || typeof inner !== "object") return undefined
const o = inner as Record<string, unknown>
const v = o.valid ?? o.Valid
if (typeof v === "boolean") return v
if (v === "true" || v === 1) return true
if (v === "false" || v === 0) return false
return undefined
}
/**
* POST /questions/validate-question-type-definition
* Success: 200 with `data.valid` (envelope `success` may still be false).
* Invalid: 400 with `message` / `error` on the JSON body (axios throws).
*/
export async function validateQuestionTypeDefinition(
body: QuestionTypeDefinitionValidatePayload,
): Promise<ValidateQuestionTypeDefinitionResult> {
try {
const res = await http.post<ApiEnvelope<unknown>>("/questions/validate-question-type-definition", body)
const envelope = res.data as ApiEnvelope<unknown>
const inner = unwrapApiPayload(res)
const validFlag = parseInnerValidFlag(inner)
if (validFlag === true) {
return { valid: true, message: envelope?.message }
}
const envErr =
typeof envelope?.error === "string"
? envelope.error
: envelope && typeof envelope === "object" && "Error" in envelope
? String((envelope as { Error?: unknown }).Error)
: undefined
return {
valid: false,
message: envelope?.message,
error:
envErr ||
(validFlag === false ? "Definition is not valid." : "Validation response did not include a valid flag."),
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
const d = err.response?.data
return {
valid: false,
message: d?.message,
error: d?.error || d?.message || "Validation request failed",
}
}
}
export async function createQuestionTypeDefinition(body: QuestionTypeDefinitionCreatePayload) {
return http.post<ApiEnvelope<unknown>>("/questions/type-definitions", body)
}
function asStr(v: unknown): string {
if (v == null || v === undefined) return ""
return String(v)
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return []
return v.filter((x): x is string => typeof x === "string" && x.length > 0)
}
function normalizeSchemaRows(v: unknown): DynamicElementDefinition[] {
if (!Array.isArray(v)) return []
return v.map((row) => {
if (!row || typeof row !== "object") {
return { id: "", kind: "", required: false, label: undefined, config: undefined }
}
const r = row as Record<string, unknown>
const id = asStr(r.id ?? r.Id)
const kind = asStr(r.kind ?? r.Kind)
const labelRaw = r.label ?? r.Label
const config = (r.config ?? r.Config) as Record<string, unknown> | undefined
return {
id,
kind,
label: labelRaw != null && labelRaw !== "" ? asStr(labelRaw) : undefined,
required: Boolean(r.required ?? r.Required),
config: config && typeof config === "object" && !Array.isArray(config) ? config : undefined,
}
})
}
/**
* Maps GET/POST definition objects from PascalCase (ID, Key, StimulusSchema, )
* or snake_case into {@link QuestionTypeDefinition}.
*/
export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefinition | null {
if (!raw || typeof raw !== "object") return null
const o = raw as Record<string, unknown>
const id = Number(o.ID ?? o.id)
if (!Number.isFinite(id)) return null
const statusRaw = asStr(o.Status ?? o.status).toUpperCase()
const status = statusRaw === "INACTIVE" ? "INACTIVE" : "ACTIVE"
return {
id,
key: asStr(o.Key ?? o.key),
display_name: asStr(o.DisplayName ?? o.display_name),
description: (() => {
const d = o.Description ?? o.description
if (d == null) return null
const s = asStr(d)
return s === "" ? null : s
})(),
stimulus_component_kinds: asStringArray(o.StimulusComponentKinds ?? o.stimulus_component_kinds),
response_component_kinds: asStringArray(o.ResponseComponentKinds ?? o.response_component_kinds),
stimulus_schema: normalizeSchemaRows(o.StimulusSchema ?? o.stimulus_schema),
response_schema: normalizeSchemaRows(o.ResponseSchema ?? o.response_schema),
status,
is_system: Boolean(o.IsSystem ?? o.is_system),
created_at: o.CreatedAt != null ? asStr(o.CreatedAt) : o.created_at != null ? asStr(o.created_at) : undefined,
updated_at: o.UpdatedAt != null ? asStr(o.UpdatedAt) : o.updated_at != null ? asStr(o.updated_at) : undefined,
}
}
/**
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
* Example update: `{ "data": { "id": 6 } }`.
*/
export function extractDefinitionMutationId(res: { data?: unknown }): number | undefined {
const data = unwrapApiPayload(res)
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
const o = data as Record<string, unknown>
const id = Number(o.ID ?? o.id ?? o.Id)
return Number.isFinite(id) && id > 0 ? id : undefined
}
/** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = extractDefinitionMutationId
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return []
if (Array.isArray(payload)) {
return payload
.map((item) => normalizeTypeDefinitionFromApi(item))
.filter((x): x is QuestionTypeDefinition => x != null)
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object") return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") {
const single = normalizeTypeDefinitionFromApi(data)
return single ? [single] : []
}
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(params?: {
include_system?: boolean
status?: string
limit?: number
offset?: number
}) {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
return parseDefinitionsList(raw)
}
export async function getQuestionTypeDefinitionById(id: number) {
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
const def = unwrapApiPayload(res)
return normalizeTypeDefinitionFromApi(def) ?? undefined
}
export async function updateQuestionTypeDefinition(
id: number,
body: QuestionTypeDefinitionUpdatePayload,
) {
return http.put<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`, body)
}
export async function deleteQuestionTypeDefinition(id: number) {
return http.delete<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
}

View File

@ -170,6 +170,10 @@ export function AppRoutes() {
path="/new-content/question-types"
element={<QuestionTypeLibraryPage />}
/>
<Route
path="/new-content/question-types/:definitionId/edit"
element={<CreateQuestionTypeFlow />}
/>
<Route
path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />}

View File

@ -12,6 +12,7 @@ import {
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
import { type ComponentType, useEffect, useState } from "react";
@ -39,6 +40,7 @@ const navItems: NavItem[] = [
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 },
{ label: "Settings", to: "/settings", icon: Settings },
];
type SidebarProps = {

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Bell,
Eye,
@ -13,21 +13,43 @@ import {
Shield,
Sun,
User,
CreditCard,
AlertTriangle,
X,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
type SettingsTab =
| "subscription"
| "profile"
| "security"
| "notifications"
| "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "subscription", label: "Subscription", icon: CreditCard },
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
@ -48,14 +70,14 @@ function Toggle({
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
enabled ? "bg-brand-500" : "bg-grayScale-200"
"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-4 w-4 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-6" : "translate-x-1"
"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>
@ -68,20 +90,20 @@ function SettingRow({
description,
children,
}: {
icon: typeof User;
icon: any;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<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-lg bg-grayScale-100 text-grayScale-400">
<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-600">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
<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>
@ -89,34 +111,143 @@ function SettingRow({
);
}
function LoadingSkeleton() {
// --- Subscription Tab ---
function SubscriptionTab() {
const [subs, setSubs] = useState([
{
id: "auto_renew",
name: "Auto-renewal",
desc: "Automatically renew your subscription when it expires",
enabled: true,
},
{
id: "marketing_emails",
name: "Marketing Emails",
desc: "Receive updates about new features and promotions",
enabled: true,
},
{
id: "priority_support",
name: "Priority Support",
desc: "Access 24/7 priority customer support",
enabled: true,
},
]);
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const handleToggle = (id: string) => {
const item = subs.find((s) => s.id === id);
if (item?.enabled) {
setPendingToggle(id);
setShowWarning(true);
} else {
setSubs((prev) =>
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
);
}
};
const confirmToggleOff = () => {
if (pendingToggle) {
setSubs((prev) =>
prev.map((s) =>
s.id === pendingToggle ? { ...s, enabled: false } : s,
),
);
setShowWarning(false);
setPendingToggle(null);
}
};
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
<div className="animate-pulse space-y-6">
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
))}
</div>
<div className="rounded-2xl border border-grayScale-100 p-6">
<div className="space-y-6">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 w-32 rounded bg-grayScale-100" />
<div className="h-3 w-48 rounded bg-grayScale-100" />
</div>
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Subscription Features
</CardTitle>
<p className="text-[11px] text-grayScale-500">
Customize your subscription experience and management preferences
</p>
</CardHeader>
<CardContent className="space-y-0 p-0">
{subs.map((sub, idx) => (
<React.Fragment key={sub.id}>
<div
className={cn(
"px-2",
idx < subs.length - 1 && "border-b border-grayScale-50",
)}
>
<SettingRow
icon={CreditCard}
title={sub.name}
description={sub.desc}
>
<Toggle
enabled={sub.enabled}
onToggle={() => handleToggle(sub.id)}
/>
</SettingRow>
</div>
))}
</React.Fragment>
))}
</CardContent>
</Card>
<Dialog open={showWarning} onOpenChange={setShowWarning}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
<div className="relative p-8">
<div className="flex items-start gap-5 mb-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
<AlertTriangle className="h-7 w-7" />
</div>
<div className="pt-1">
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
Are you absolutely sure?
</h3>
<p className="text-sm text-grayScale-500 mt-1">
Disabling this feature might limit your experience.
</p>
</div>
</div>
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
By turning this off, you will no longer receive the benefits
associated with this feature. Some changes might take up to 24
hours to reflect.
</p>
</div>
<div className="flex flex-col gap-3">
<Button
variant="destructive"
onClick={confirmToggleOff}
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
>
Yes, Disable Feature
</Button>
<Button
variant="outline"
onClick={() => setShowWarning(false)}
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
>
Cancel
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Other Tabs (Existing, but with sidebar layout updates) ---
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
@ -142,79 +273,88 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
};
return (
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
</div>
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-500" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">First Name</label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
First Name
</label>
<Input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="rounded-[6px]"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Last Name
</label>
<Input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="rounded-[6px]"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Nickname
</label>
<Input
value={nickName}
onChange={(e) => setNickName(e.target.value)}
className="rounded-[6px]"
/>
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Languages className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Preferences
</CardTitle>
</div>
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Preferences
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Preferred Language
</label>
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="rounded-[6px]"
>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Afan Oromo</option>
<option value="ti">Tigrinya</option>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
<Select defaultValue="eat">
<option value="eat">East Africa Time (UTC+3)</option>
<option value="utc">UTC</option>
<option value="est">Eastern Time (UTC-5)</option>
<option value="pst">Pacific Time (UTC-8)</option>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
<Button
onClick={handleSave}
disabled={saving}
className="min-w-[140px] rounded-[6px] font-bold"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="h-4 w-4" />
<Save className="h-4 w-4 mr-2" />
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
@ -240,96 +380,102 @@ function SecurityTab() {
};
return (
<div className="space-y-6">
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<KeyRound className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Change Password
</CardTitle>
</div>
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<div className="h-1 w-full bg-brand-600" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Change Password
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Current Password
</label>
<div className="relative">
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
<Input
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
className="rounded-[6px]"
/>
<button
type="button"
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showCurrent ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">New Password</label>
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
New Password
</label>
<div className="relative">
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
<Input
type={showNew ? "text" : "password"}
placeholder="Enter new password"
className="rounded-[6px]"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showNew ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Confirm New Password
</label>
<div className="relative">
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
<Input
type={showConfirm ? "text" : "password"}
placeholder="Confirm new password"
className="rounded-[6px]"
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showConfirm ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
<Button
onClick={handleChangePassword}
disabled={saving}
className="min-w-[160px] rounded-[6px] font-bold"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
<Lock className="h-4 w-4 mr-2" />
)}
{saving ? "Updating…" : "Update Password"}
</Button>
</div>
</CardContent>
</Card>
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-400 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Two-Factor Authentication
</CardTitle>
</div>
</CardHeader>
<CardContent className="pb-6">
<SettingRow
icon={Shield}
title="Enable 2FA"
description="Add an extra layer of security to your account"
>
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
</SettingRow>
</CardContent>
</Card>
</div>
);
}
@ -338,20 +484,14 @@ function NotificationsTab() {
const [emailNotifs, setEmailNotifs] = useState(true);
const [pushNotifs, setPushNotifs] = useState(true);
const [loginAlerts, setLoginAlerts] = useState(true);
const [weeklyDigest, setWeeklyDigest] = useState(false);
return (
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<Bell className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Notification Preferences
</CardTitle>
</div>
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-500" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Notification Preferences
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 pb-6">
<SettingRow
@ -359,31 +499,32 @@ function NotificationsTab() {
title="Email Notifications"
description="Receive important updates via email"
>
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
<Toggle
enabled={emailNotifs}
onToggle={() => setEmailNotifs(!emailNotifs)}
/>
</SettingRow>
<Separator />
<Separator className="bg-grayScale-50" />
<SettingRow
icon={Bell}
title="Push Notifications"
description="Get notified in the browser"
>
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
<Toggle
enabled={pushNotifs}
onToggle={() => setPushNotifs(!pushNotifs)}
/>
</SettingRow>
<Separator />
<Separator className="bg-grayScale-50" />
<SettingRow
icon={Shield}
title="Login Alerts"
description="Get notified when someone logs into your account"
>
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
</SettingRow>
<Separator />
<SettingRow
icon={Globe}
title="Weekly Digest"
description="Receive a weekly summary of activity"
>
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
<Toggle
enabled={loginAlerts}
onToggle={() => setLoginAlerts(!loginAlerts)}
/>
</SettingRow>
</CardContent>
</Card>
@ -394,17 +535,12 @@ function AppearanceTab() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
return (
<Card className="border border-grayScale-100">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
<Palette className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Theme
</CardTitle>
</div>
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Theme
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
@ -420,16 +556,18 @@ function AppearanceTab() {
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
theme === id
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
"flex h-10 w-10 items-center justify-center rounded-[6px]",
theme === id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-400",
)}
>
<Icon className="h-5 w-5" />
@ -444,7 +582,7 @@ function AppearanceTab() {
}
export function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
const [activeTab, setActiveTab] = useState<SettingsTab>("subscription");
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -464,21 +602,27 @@ export function SettingsPage() {
fetchProfile();
}, []);
if (loading) return <LoadingSkeleton />;
if (loading) {
return (
<div className="flex h-[400px] items-center justify-center">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
</div>
);
}
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card className="border-dashed">
<Card className="border-dashed border-grayScale-200 rounded-[6px]">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
<User className="h-10 w-10 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
<p className="text-lg font-bold tracking-tight text-grayScale-900">
{error || "Settings not available"}
</p>
<p className="mt-1 text-sm text-grayScale-400">
<p className="mt-1 text-sm text-grayScale-500">
Please check your connection and try again.
</p>
</div>
@ -489,40 +633,23 @@ export function SettingsPage() {
}
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage your account preferences and configuration
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
<div className="mb-10 ">
<h1 className="text-2xl font-black tracking-tight text-grayScale-700">
Settings
</h1>
<p className="mt-2 text-sm text-grayScale-500 ">
Manage your account preferences, subscriptions, and system
configurations with ease
</p>
</div>
{/* Tab navigation */}
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setActiveTab(id)}
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
activeTab === id
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-400 hover:text-grayScale-600"
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
<div className="flex flex-col gap-8">
{/* Content Area */}
<main className="min-h-[400px]">
{activeTab === "subscription" && <SubscriptionTab />}
</main>
</div>
{/* Tab content */}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />}
</div>
);
}

View File

@ -8,11 +8,18 @@ import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question {
id?: number
question: string
@ -27,6 +34,9 @@ interface Question {
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
}
const initialForm: Question = {
@ -42,6 +52,8 @@ const initialForm: Question = {
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
export function AddQuestionPage() {
@ -52,6 +64,7 @@ export function AddQuestionPage() {
const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
useEffect(() => {
const loadQuestion = async () => {
@ -64,7 +77,8 @@ export function AddQuestionPage() {
q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO"
q.question_type === "AUDIO" ||
q.question_type === "DYNAMIC"
? q.question_type
: "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -100,6 +114,14 @@ export function AddQuestionPage() {
voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "",
questionTypeDefinitionId:
mappedType === "DYNAMIC" && q.question_type_definition_id != null
? String(q.question_type_definition_id)
: "",
dynamicPayloadJson:
mappedType === "DYNAMIC" && q.dynamic_payload
? JSON.stringify(q.dynamic_payload, null, 2)
: defaultDynamicPayloadJson,
})
} catch (error) {
console.error("Failed to load question:", error)
@ -111,6 +133,22 @@ export function AddQuestionPage() {
loadQuestion()
}, [isEditing, id])
useEffect(() => {
if (formData.type !== "DYNAMIC") return
let cancelled = false
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
} catch {
if (!cancelled) setTypeDefinitions([])
}
})()
return () => {
cancelled = true
}
}, [formData.type])
const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => {
if (type === "TRUE_FALSE") {
@ -120,6 +158,15 @@ export function AddQuestionPage() {
options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
}
} else if (type === "DYNAMIC") {
return {
...prev,
type,
options: [],
correctAnswer: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return {
...prev,
@ -200,6 +247,27 @@ export function AddQuestionPage() {
})
return
}
} else if (formData.type === "DYNAMIC") {
const defId = Number(formData.questionTypeDefinitionId)
if (!Number.isFinite(defId) || defId < 1) {
toast.error("Definition required", { description: "Select a question type definition." })
return
}
try {
const parsed = JSON.parse(formData.dynamicPayloadJson || "{}") as {
stimulus?: unknown
response?: unknown
}
if (!Array.isArray(parsed.stimulus) || !Array.isArray(parsed.response)) {
toast.error("Invalid dynamic payload", {
description: 'JSON must include "stimulus" and "response" arrays.',
})
return
}
} catch {
toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." })
return
}
}
setSubmitting(true)
@ -221,6 +289,18 @@ export function AddQuestionPage() {
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
]
: undefined
let dynamicPayload: { stimulus: unknown[]; response: unknown[] } | undefined
if (formData.type === "DYNAMIC") {
try {
dynamicPayload = JSON.parse(formData.dynamicPayloadJson) as {
stimulus: unknown[]
response: unknown[]
}
} catch {
dynamicPayload = { stimulus: [], response: [] }
}
}
const payload = {
question_text: formData.question,
question_type: formData.type,
@ -236,6 +316,12 @@ export function AddQuestionPage() {
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
...(formData.type === "DYNAMIC" && dynamicPayload
? {
question_type_definition_id: Number(formData.questionTypeDefinitionId),
dynamic_payload: dynamicPayload,
}
: {}),
}
if (isEditing && id) {
await updateQuestion(Number(id), payload)
@ -303,15 +389,58 @@ export function AddQuestionPage() {
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select>
</div>
{formData.type === "DYNAMIC" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={formData.questionTypeDefinitionId}
onChange={(e) =>
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
}
required
>
<option value="">Select definition</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.display_name} ({d.key})
</option>
))}
</Select>
<p className="mt-1 text-xs text-grayScale-400">
Loaded from GET /questions/type-definitions?include_system=true&amp;status=ACTIVE
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
dynamic_payload (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={12}
className="font-mono text-xs"
spellCheck={false}
/>
<p className="mt-1 text-xs text-grayScale-400">
Must match the selected definition&apos;s stimulus/response schema (see integration guide).
</p>
</div>
</>
)}
<hr className="border-grayScale-100" />
{/* Question Text */}
<div>
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Question
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"}
</label>
<Textarea
id="question"
@ -368,6 +497,7 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" />
{/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
@ -403,6 +533,7 @@ export function AddQuestionPage() {
/>
)}
</div>
)}
<hr className="border-grayScale-100" />

View File

@ -1,10 +1,11 @@
import { Outlet } from "react-router-dom"
import { Outlet } from "react-router-dom";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ContentManagementLayout() {
return (
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 mb-8">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
@ -15,9 +16,11 @@ export function ContentManagementLayout() {
</p>
</div>
</div>
<ContentHierarchyList />
</div>
<Outlet />
</div>
)
);
}

View File

@ -39,8 +39,7 @@ import type {
import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
const MODULE_CARD_GRADIENT =
"from-[#8E44AD] to-[#C39BD3]" as const;
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
function isLikelyImageUrl(src: string): boolean {
const t = src.trim();
@ -54,7 +53,8 @@ function isLikelyImageUrl(src: string): boolean {
function isSignedMinioUrl(src: string): boolean {
const value = src.trim();
if (!value.startsWith("http://") && !value.startsWith("https://")) return false;
if (!value.startsWith("http://") && !value.startsWith("https://"))
return false;
try {
const url = new URL(value);
return url.searchParams.has("X-Amz-Signature");
@ -308,8 +308,7 @@ export function CourseDetailPage() {
}
};
const displayTitle =
course?.name?.trim() || courseIdParam || "Course";
const displayTitle = course?.name?.trim() || courseIdParam || "Course";
const displayDescription =
course?.description?.trim() ||
(!loading && !course
@ -502,9 +501,7 @@ export function CourseDetailPage() {
key={module.id}
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
>
<div
className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
>
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="button"
variant="secondary"
@ -536,8 +533,10 @@ export function CourseDetailPage() {
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400">
{module.description?.trim() ? module.description : "—"}
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description?.trim()
? module.description
: "—"}
</p>
</div>
</div>
@ -552,7 +551,8 @@ export function CourseDetailPage() {
{
state: {
moduleName: module.name,
moduleDescription: module.description?.trim() ?? "",
moduleDescription:
module.description?.trim() ?? "",
},
},
)

View File

@ -1,31 +1,279 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Stepper } from "../../components/ui/stepper"
import {
createQuestionTypeDefinition,
validateQuestionTypeDefinition,
extractDefinitionMutationId,
getQuestionComponentCatalog,
getQuestionTypeDefinitionById,
updateQuestionTypeDefinition,
} from "../../api/questionTypeDefinitions.api"
import type {
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
type FieldErrorMap,
} from "./lib/questionTypeDefinitionValidation"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
display_name: "",
description: "",
status: "ACTIVE",
stimulus_component_kinds: [],
response_component_kinds: [],
stimulus_schema: [],
response_schema: [],
})
function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: k.replace(/_/g, " "),
required: true as boolean,
}))
}
function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionCreatePayload {
return {
key: def.key,
display_name: def.display_name,
description: def.description ?? "",
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
}
}
export function CreateQuestionTypeFlow() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const navigate = useNavigate()
const { definitionId: definitionIdParam } = useParams<{ definitionId?: string }>()
const editDefinitionId = useMemo(() => {
if (!definitionIdParam || !/^\d+$/.test(definitionIdParam)) return null
const n = Number(definitionIdParam)
return Number.isFinite(n) && n > 0 ? n : null
}, [definitionIdParam])
const isEdit = editDefinitionId != null
const steps = [
"Basic Info",
"Input & Answer Configuration",
"Versions",
"Review & Publish",
];
const [currentStep, setCurrentStep] = useState(1)
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
const [versionName, setVersionName] = useState("Test 1")
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
const [definitionReady, setDefinitionReady] = useState(!isEdit)
const handleNext = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
stimulus_component_kinds: [],
response_component_kinds: [],
})
const [catalogLoading, setCatalogLoading] = useState(true)
const [catalogError, setCatalogError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setCatalogLoading(true)
setCatalogError(null)
try {
const cat = await getQuestionComponentCatalog()
if (!cancelled) {
setComponentCatalog(cat)
if (
!cat.stimulus_component_kinds.length &&
!cat.response_component_kinds.length
) {
setCatalogError("Catalog returned no kinds — check API response shape.")
}
}
} catch (e) {
if (!cancelled) {
console.error(e)
setCatalogError("Failed to load component catalog.")
toast.error("Failed to load component catalog")
}
} finally {
if (!cancelled) setCatalogLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isEdit || editDefinitionId == null) {
setDefinitionReady(true)
return
}
let cancelled = false
setDefinitionReady(false)
;(async () => {
try {
const def = await getQuestionTypeDefinitionById(editDefinitionId)
if (cancelled) return
if (!def) {
toast.error("Definition not found")
navigate("/new-content/question-types")
return
}
setDraft(definitionToDraft(def))
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
} catch (e) {
if (!cancelled) {
console.error(e)
toast.error("Failed to load definition")
navigate("/new-content/question-types")
}
} finally {
if (!cancelled) setDefinitionReady(true)
}
})()
return () => {
cancelled = true
}
}, [isEdit, editDefinitionId, navigate])
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
}
}, [isEdit])
const catalogForSchemaValidation = {
stimulus: new Set(componentCatalog.stimulus_component_kinds),
response: new Set(componentCatalog.response_component_kinds),
}
const handleNextFromStep1 = () => {
const e1 = validateDefinitionBasic(draft)
setStepErrors(e1)
if (Object.keys(e1).length) {
toast.error("Fix the highlighted fields before continuing.")
return
}
setStepErrors({})
setCurrentStep(2)
}
const handleNextFromStep2 = () => {
const versionErr: FieldErrorMap = {}
if (!versionName.trim()) {
versionErr.version_name = "Version name is required."
}
const eKinds = validateDefinitionKinds(draft, componentCatalog)
const mergedKinds = { ...versionErr, ...eKinds }
setStepErrors(mergedKinds)
if (Object.keys(mergedKinds).length) {
toast.error("Complete version name and component selections.")
return
}
const nextDraft: QuestionTypeDefinitionCreatePayload = { ...draft }
if (!nextDraft.stimulus_schema.length && nextDraft.stimulus_component_kinds.length) {
nextDraft.stimulus_schema = seedSchemaFromKinds(nextDraft.stimulus_component_kinds)
}
if (!nextDraft.response_schema.length && nextDraft.response_component_kinds.length) {
nextDraft.response_schema = seedSchemaFromKinds(nextDraft.response_component_kinds)
}
setDraft(nextDraft)
const mergedSchema = validateDefinitionSchemas(nextDraft, catalogForSchemaValidation)
setStepErrors(mergedSchema)
if (Object.keys(mergedSchema).length) {
toast.error("Fix schema issues (expand Advanced) or adjust selected kinds.", {
description: "Open “Advanced: edit schema rows” to fix row ids and kinds.",
})
return
}
setStepErrors({})
setCurrentStep(3)
}
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1))
const handleHeaderSaveDraft = async () => {
setDraft((d) => ({ ...d, status: "INACTIVE" }))
if (currentStep < 4) {
toast.message("Status set to Inactive", {
description: "Complete the wizard; on the last step you can save the definition to the server.",
})
return
}
const body = { ...buildCreatePayload({ ...draft, status: "INACTIVE" }), status: "INACTIVE" as const }
try {
if (isEdit && editDefinitionId != null) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(res.data?.message ?? "Save failed: missing definition id in response.")
return
}
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
toast.error(String(err.response?.data?.message || "Save failed"), {
description: err.response?.data?.error ? String(err.response.data.error) : undefined,
})
}
}
const steps = ["Basic Info", "Input & answer types", "Validate", "Review & publish"]
if (isEdit && !definitionReady) {
return (
<div className="min-h-screen pb-20 flex items-center justify-center px-6">
<Card className="p-10 max-w-md w-full text-center border-grayScale-200">
<p className="text-grayScale-600 font-medium">Loading definition</p>
</Card>
</div>
)
}
return (
<div className="min-h-screen pb-20 overflow-x-hidden">
{/* Header */}
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
<div className="max-w-[1440px] mx-auto py-6">
<div className=" border-b border-grayScale-100 sticky top-0 z-50 bg-white/95 backdrop-blur">
<div className="max-w-[1440px] mx-auto py-6 px-4 sm:px-6">
<div className="flex items-center justify-between mb-8">
<Link
to="/new-content/question-types"
@ -36,16 +284,29 @@ export function CreateQuestionTypeFlow() {
</Link>
</div>
<div className="flex items-start justify-between">
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
Create Question Type
{isEdit ? "Edit question type definition" : "Create question type definition"}
</h1>
<p className="text-grayScale-500 text-[14px] font-medium">
Create a new immersive practice session for students.
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit ? (
<>
Update definition{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 shrink-0">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
@ -53,7 +314,10 @@ export function CreateQuestionTypeFlow() {
>
Cancel
</Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all">
<Button
className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"
onClick={handleHeaderSaveDraft}
>
Save as Draft
</Button>
</div>
@ -65,23 +329,38 @@ export function CreateQuestionTypeFlow() {
</div>
</div>
{/* Main Content */}
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
{currentStep === 2 && (
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
<div className="max-w-[1440px] mx-auto px-4 sm:px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
{currentStep === 1 && (
<QuestionTypeBasicInfoStep
draft={draft}
setDraft={setDraft}
errors={stepErrors}
keyReadOnly={isEdit}
onNext={handleNextFromStep1}
/>
)}
{currentStep > 2 && (
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
<p className="text-grayScale-400 font-medium">
Step {currentStep} implementation in progress...
</p>
<Button onClick={handleBack} variant="outline" className="mt-4">
Go Back
</Button>
</div>
{currentStep === 2 && (
<QuestionTypeConfigStep
draft={draft}
setDraft={setDraft}
versionName={versionName}
setVersionName={setVersionName}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading}
catalogError={catalogError}
errors={stepErrors}
onNext={handleNextFromStep2}
onBack={handleBack}
/>
)}
{currentStep === 3 && (
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
)}
</div>
</div>
);
)
}

View File

@ -1,50 +1,116 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { ArrowLeft, Plus, Search } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Card } from "../../components/ui/card";
import { cn } from "../../lib/utils";
import { QuestionTypeCard } from "./components/QuestionTypeCard";
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
getQuestionTypeDefinitions,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
export function QuestionTypeLibraryPage() {
const [activeTab, setActiveTab] = useState("All");
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const questionTypes = [
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Write About the Topic",
exam: "DUOLINGO" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Fill in the Blanks",
exam: "IELTS" as const,
skill: "Writing" as const,
variations: 12,
status: "Published" as const,
},
{
title: "Describe a Photo",
exam: "DUOLINGO" as const,
skill: "Speaking" as const,
variations: 12,
status: "Published" as const,
},
];
const [loading, setLoading] = useState(true)
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
const [query, setQuery] = useState("")
const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
setDefinitions(Array.isArray(rows) ? rows : [])
} catch (e) {
console.error(e)
toast.error("Failed to load question type definitions")
setDefinitions([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (createdId) {
toast.success("Definition created", { description: `Id ${createdId}` })
}
}, [createdId])
useEffect(() => {
if (updatedId) {
toast.success("Definition updated", { description: `Id ${updatedId}` })
}
}, [updatedId])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return definitions.filter((d) => {
if (activeTab !== "All") {
const st = (d.status || "").toString().toUpperCase()
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
}
if (!q) return true
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query, activeTab])
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
toast.error("System definitions cannot be deleted.")
return
}
setDefinitionPendingDelete(row)
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open && !deleteSubmitting) setDefinitionPendingDelete(null)
}
const handleConfirmDeleteDefinition = async () => {
const row = definitionPendingDelete
if (!row) return
setDeleteSubmitting(true)
try {
await deleteQuestionTypeDefinition(row.id)
toast.success("Definition deleted")
setDefinitionPendingDelete(null)
void load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(String(err.response?.data?.message || "Delete failed"))
} finally {
setDeleteSubmitting(false)
}
}
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6">
<Link
to="/new-content/courses"
@ -54,54 +120,43 @@ export function QuestionTypeLibraryPage() {
Back to Courses
</Link>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
Question Type Library
</h1>
<p className="text-grayScale-500 text-[16px] font-medium">
Create and manage reusable question structures for practices and
assessments.
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
</p>
</div>
<Link to="/new-content/question-types/create">
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
<Plus className="h-5 w-5" />
Create Question Type
Create definition
</Button>
</Link>
</div>
</div>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex items-center gap-4">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by practice name, ID, or keywords..."
placeholder="Search by display name, key, or id…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Exams</option>
<option>IELTS</option>
<option>Duolingo</option>
</Select>
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
<option>All Skills</option>
<option>Speaking</option>
<option>Writing</option>
</Select>
</div>
<div className="flex items-center gap-3">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
STATUS:
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
@ -110,18 +165,82 @@ export function QuestionTypeLibraryPage() {
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
>
{tab}
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
</button>
))}
</div>
</Card>
{/* Grid of Cards */}
<div className="grid grid-cols-4 gap-6">
{questionTypes.map((qt, index) => (
<QuestionTypeCard key={index} {...qt} />
))}
</div>
{loading ? (
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p>
) : filtered.length === 0 ? (
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{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>
)}
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
<Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
Delete question type definition?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This removes the template from the library. Existing questions that reference it may be affected. This
action cannot be undone.
</DialogDescription>
</DialogHeader>
{definitionPendingDelete ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3 space-y-1">
<p className="text-sm font-semibold text-grayScale-900">{definitionPendingDelete.display_name}</p>
<p className="text-xs font-mono text-grayScale-500 break-all">
#{definitionPendingDelete.id} · {definitionPendingDelete.key}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
className="border-grayScale-200"
disabled={deleteSubmitting}
onClick={() => setDefinitionPendingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteSubmitting}
className="gap-2"
onClick={() => void handleConfirmDeleteDefinition()}
>
{deleteSubmitting ? <SpinnerIcon className="h-4 w-4" /> : <Trash2 className="h-4 w-4" aria-hidden />}
{deleteSubmitting ? "Deleting…" : "Delete definition"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
)
}

View File

@ -0,0 +1,612 @@
import React, { useState, useEffect, useCallback } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from "@dnd-kit/core";
import type {
DragEndEvent,
DragStartEvent,
UniqueIdentifier,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
GripVertical,
ChevronDown,
ChevronRight,
LayoutGrid,
BookOpen,
Layers,
PlayCircle,
RotateCcw,
Edit2,
Trash2,
Image as ImageIcon,
Loader2,
} from "lucide-react";
import { cn } from "../../../lib/utils";
import {
getLearningPrograms,
getProgramCourses,
getTopLevelCourseModules,
getModuleLessons,
} from "../../../api/courses.api";
// --- Types ---
export type ItemType = "program" | "course" | "module" | "lesson";
export interface BaseItem {
id: string;
name: string;
thumbnail?: string;
}
export interface Program extends BaseItem {}
export interface Course extends BaseItem {
programId: string;
}
export interface Module extends BaseItem {
courseId: string;
}
export interface Lesson extends BaseItem {
moduleId: string;
}
// --- Components ---
interface SortableItemProps {
id: string;
name: string;
icon: React.ReactNode;
thumbnail?: string;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
function SortableItem({
id,
name,
icon,
thumbnail,
onEdit,
onDelete,
}: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center justify-between px-4 py-3 border border-grayScale-200 rounded-[6px] mb-2 bg-white transition-all duration-200 group/item",
isDragging && "opacity-50 border-dashed z-50 shadow-sm",
!isDragging && "hover:border-brand-200 hover:shadow-sm",
)}
>
<div className="flex items-center gap-4">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 text-grayScale-300 hover:text-brand-500 transition-colors"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
{/* Thumbnail/Icon Container */}
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
{thumbnail ? (
<img
src={thumbnail}
alt={name}
className="h-full w-full object-cover"
/>
) : (
<div className="text-grayScale-400 group-hover/item:text-brand-500 transition-colors">
{icon}
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-[14px] font-bold text-grayScale-800 leading-tight">
{name}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity">
<button
onClick={() => onEdit?.(id)}
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
title="Edit"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDelete?.(id)}
className="p-2 text-grayScale-400 rounded-[4px] transition-all"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
interface DraggableListProps {
items: BaseItem[];
onReorder: (activeId: string, overId: string) => void;
icon: React.ReactNode;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
function DraggableList({
items,
onReorder,
icon,
onEdit,
onDelete,
}: DraggableListProps) {
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) =>
setActiveId(event.active.id);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(active.id as string, over.id as string);
}
setActiveId(null);
};
const activeItem = items.find((i) => i.id === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{items.map((item) => (
<SortableItem
key={item.id}
id={item.id}
name={item.name}
thumbnail={item.thumbnail}
icon={icon}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeItem ? (
<div className="flex items-center justify-between px-4 py-3 bg-white border border-brand-300 shadow-lg rounded-[6px] opacity-90 cursor-grabbing">
<div className="flex items-center gap-4">
<div className="p-1 ">
<GripVertical className="h-4 w-4" />
</div>
<div className="flex items-center gap-3">
<div className="h-10 w-10 shrink-0 rounded-[4px] bg-grayScale-50 border border-grayScale-100 flex items-center justify-center overflow-hidden">
{activeItem.thumbnail ? (
<img
src={activeItem.thumbnail}
alt={activeItem.name}
className="h-full w-full object-cover"
/>
) : (
<div className="text-brand-500">{icon}</div>
)}
</div>
<span className="text-[14px] font-bold text-grayScale-800">
{activeItem.name}
</span>
</div>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
interface SectionProps {
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function HierarchySection({
title,
icon,
isOpen,
onToggle,
children,
}: SectionProps) {
return (
<div className="border border-grayScale-100 rounded-xl mb-3 overflow-hidden transition-all duration-300 bg-white">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center justify-between px-5 py-4 transition-colors",
isOpen ? "bg-grayScale-50" : "hover:bg-grayScale-25",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"p-2 rounded-lg transition-colors",
isOpen
? "bg-brand-300 text-white"
: "bg-grayScale-50 text-grayScale-500",
)}
>
{icon}
</div>
<span
className={cn(
"text-[15px] font-bold",
isOpen ? "text-grayScale-900" : "text-grayScale-700",
)}
>
{title}
</span>
</div>
{isOpen ? (
<ChevronDown className="h-5 w-5 text-grayScale-400" />
) : (
<ChevronRight className="h-5 w-5 text-grayScale-400" />
)}
</button>
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
isOpen ? "max-h-[1000px] opacity-100 p-5 pt-0" : "max-h-0 opacity-0",
)}
>
<div className="pt-4 border-t border-grayScale-200">{children}</div>
</div>
</div>
);
}
export function ContentHierarchyList() {
const [programs, setPrograms] = useState<Program[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [modules, setModules] = useState<Module[]>([]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
program: true,
});
const fetchHierarchy = useCallback(async () => {
setLoading({ program: true });
try {
// 1. Fetch Programs
const programsRes = await getLearningPrograms();
const programData = programsRes.data?.data;
const fetchedPrograms: Program[] = (programData?.programs || []).map(
(p) => ({
id: String(p.id),
name: p.name,
thumbnail: p.thumbnail || undefined,
}),
);
setPrograms(fetchedPrograms);
setLoading((prev) => ({ ...prev, program: false }));
if (fetchedPrograms.length === 0) return;
// 2. Fetch Courses for all programs
setLoading((prev) => ({ ...prev, course: true }));
const coursesPromises = fetchedPrograms.map((p) =>
getProgramCourses(Number(p.id)),
);
const coursesResults = await Promise.all(coursesPromises);
const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => {
const courseData = res.data?.data;
return (courseData?.courses || []).map((c) => ({
id: String(c.id),
name: c.name,
thumbnail: c.thumbnail_url || c.thumbnail || undefined,
programId: fetchedPrograms[idx].id,
}));
});
setCourses(fetchedCourses);
setLoading((prev) => ({ ...prev, course: false }));
if (fetchedCourses.length === 0) return;
// 3. Fetch Modules for all courses
setLoading((prev) => ({ ...prev, module: true }));
const modulesPromises = fetchedCourses.map((c) =>
getTopLevelCourseModules(Number(c.id)),
);
const modulesResults = await Promise.all(modulesPromises);
const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => {
const moduleData = res.data?.data;
return (moduleData?.modules || []).map((m) => ({
id: String(m.id),
name: m.name,
thumbnail: m.icon || undefined,
courseId: fetchedCourses[idx].id,
}));
});
setModules(fetchedModules);
setLoading((prev) => ({ ...prev, module: false }));
if (fetchedModules.length === 0) return;
// 4. Fetch Lessons for all modules
setLoading((prev) => ({ ...prev, lesson: true }));
const lessonsPromises = fetchedModules.map((m) =>
getModuleLessons(Number(m.id)),
);
const lessonsResults = await Promise.all(lessonsPromises);
const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => {
const lessonData = res.data?.data;
return (lessonData?.lessons || []).map((l) => ({
id: String(l.id),
name: l.title,
thumbnail: l.thumbnail || undefined,
moduleId: fetchedModules[idx].id,
}));
});
setLessons(fetchedLessons);
} catch (error) {
console.error("Failed to fetch content hierarchy:", error);
} finally {
setLoading({});
}
}, []);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
const toggleSection = (id: string) => {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const reorder = <T extends BaseItem>(
list: T[],
setList: React.Dispatch<React.SetStateAction<T[]>>,
activeId: UniqueIdentifier,
overId: UniqueIdentifier,
) => {
const oldIndex = list.findIndex((i) => i.id === String(activeId));
const newIndex = list.findIndex((i) => i.id === String(overId));
if (oldIndex !== -1 && newIndex !== -1) {
setList(arrayMove(list, oldIndex, newIndex));
}
};
const handleEdit = (type: ItemType, id: string) => {
console.log(`Edit ${type}: ${id}`);
};
const handleDelete = (type: ItemType, id: string) => {
if (!window.confirm(`Are you sure you want to delete this ${type}?`))
return;
switch (type) {
case "program":
setPrograms((prev) => prev.filter((p) => p.id !== id));
break;
case "course":
setCourses((prev) => prev.filter((c) => c.id !== id));
break;
case "module":
setModules((prev) => prev.filter((m) => m.id !== id));
break;
case "lesson":
setLessons((prev) => prev.filter((l) => l.id !== id));
break;
}
};
const handleReset = () => {
fetchHierarchy();
};
return (
<div className="bg-[#ffffff] rounded-2xl p-6 border border-grayScale-100 mb-8 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">
Content Hierarchy
</h3>
<p className="text-[12px] text-grayScale-500 mt-1">
Manage the ordering and structure of your educational content
</p>
</div>
<button
onClick={handleReset}
className="text-[13px] font-bold text-brand-300 hover:text-brand-400 transition-colors flex items-center gap-2 group"
>
<RotateCcw className="h-4 w-4 transition-transform group-hover:rotate-[-45deg]" />
Sync with API
</button>
</div>
<div className="space-y-4">
{/* Program Section */}
<HierarchySection
title="Programs"
icon={<LayoutGrid className="h-5 w-5" />}
isOpen={openSections.program}
onToggle={() => toggleSection("program")}
>
{loading.program ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-brand-500 animate-spin" />
</div>
) : (
<DraggableList
items={programs}
onReorder={(active, over) =>
reorder(programs, setPrograms, active, over)
}
icon={<LayoutGrid className="h-4 w-4" />}
onEdit={(id) => handleEdit("program", id)}
onDelete={(id) => handleDelete("program", id)}
/>
)}
</HierarchySection>
{/* Course Section */}
<HierarchySection
title="Courses"
icon={<BookOpen className="h-5 w-5" />}
isOpen={openSections.course}
onToggle={() => toggleSection("course")}
>
{loading.course ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-brand-500 animate-spin" />
</div>
) : (
programs.map((program) => {
const programCourses = courses.filter(
(c) => c.programId === program.id,
);
if (programCourses.length === 0) return null;
return (
<div key={program.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{program.name}
</h4>
<DraggableList
items={programCourses}
onReorder={(active, over) =>
reorder(courses, setCourses, active, over)
}
icon={<BookOpen className="h-4 w-4" />}
onEdit={(id) => handleEdit("course", id)}
onDelete={(id) => handleDelete("course", id)}
/>
</div>
);
})
)}
</HierarchySection>
{/* Module Section */}
<HierarchySection
title="Modules"
icon={<Layers className="h-5 w-5" />}
isOpen={openSections.module}
onToggle={() => toggleSection("module")}
>
{loading.module ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-brand-500 animate-spin" />
</div>
) : (
courses.map((course) => {
const courseModules = modules.filter(
(m) => m.courseId === course.id,
);
if (courseModules.length === 0) return null;
return (
<div key={course.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{course.name}
</h4>
<DraggableList
items={courseModules}
onReorder={(active, over) =>
reorder(modules, setModules, active, over)
}
icon={<Layers className="h-4 w-4" />}
onEdit={(id) => handleEdit("module", id)}
onDelete={(id) => handleDelete("module", id)}
/>
</div>
);
})
)}
</HierarchySection>
{/* Lesson Section */}
<HierarchySection
title="Lessons"
icon={<PlayCircle className="h-5 w-5" />}
isOpen={openSections.lesson}
onToggle={() => toggleSection("lesson")}
>
{loading.lesson ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-brand-500 animate-spin" />
</div>
) : (
modules.map((module) => {
const moduleLessons = lessons.filter(
(l) => l.moduleId === module.id,
);
if (moduleLessons.length === 0) return null;
return (
<div key={module.id} className="mb-4 last:mb-0">
<h4 className="text-[12px] font-bold text-grayScale-400 uppercase tracking-wider mb-2 px-1">
{module.name}
</h4>
<DraggableList
items={moduleLessons}
onReorder={(active, over) =>
reorder(lessons, setLessons, active, over)
}
icon={<PlayCircle className="h-4 w-4" />}
onEdit={(id) => handleEdit("lesson", id)}
onDelete={(id) => handleDelete("lesson", id)}
/>
</div>
);
})
)}
</HierarchySection>
</div>
</div>
);
}

View File

@ -1,83 +1,90 @@
import { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react";
import { Badge } from "../../../components/ui/badge";
import { Card } from "../../../components/ui/card";
import { cn } from "../../../lib/utils";
import { Edit2, Trash2, Layers, Shield } from "lucide-react"
import { Badge } from "../../../components/ui/badge"
import { Card } from "../../../components/ui/card"
import { Button } from "../../../components/ui/button"
import { cn } from "../../../lib/utils"
interface QuestionTypeCardProps {
title: string;
exam: "DUOLINGO" | "IELTS" | "TOEFL";
skill: "Speaking" | "Writing" | "Listening" | "Reading";
variations: number;
status: "Published" | "Draft" | "Archived";
export interface QuestionTypeDefinitionCardModel {
id: number
definitionKey: string
display_name: string
status?: string
is_system?: boolean
stimulusKindsCount: number
responseKindsCount: number
onEdit?: () => void
onDelete?: () => void
deleteDisabled?: boolean
}
export function QuestionTypeCard({
title,
exam,
skill,
variations,
id,
definitionKey,
display_name,
status,
}: QuestionTypeCardProps) {
const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard;
const examColors = {
DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent",
IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent",
TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent",
};
const statusColors = {
Published: "bg-[#F0FDF4] text-[#16A34A]",
Draft: "bg-grayScale-50 text-grayScale-500",
Archived: "bg-red-50 text-red-500",
};
is_system,
stimulusKindsCount,
responseKindsCount,
onEdit,
onDelete,
deleteDisabled,
}: QuestionTypeDefinitionCardModel) {
const statusLabel = (status || "—").toString()
const isActive = statusLabel.toUpperCase() === "ACTIVE"
return (
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
<div className="px-4 py-6 space-y-8">
<h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]">
{title}
</h3>
<div className="flex items-center justify-between">
<Badge
className={cn(
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
examColors[exam],
)}
>
{exam}
</Badge>
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]">
<SkillIcon className="h-4 w-4" />
{skill}
</div>
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
<div className="px-4 py-6 space-y-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-[18px] font-bold text-grayScale-900 leading-[1.2]">{display_name}</h3>
{is_system ? (
<Badge className="shrink-0 border-none bg-violet-100 text-violet-800 flex items-center gap-1">
<Shield className="h-3 w-3" />
System
</Badge>
) : null}
</div>
<div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
<Layers className="h-[16px] w-[16px]" />
{variations} Variations
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
<div className="flex flex-wrap items-center gap-2 text-grayScale-700 font-medium text-[14px]">
<Layers className="h-4 w-4 text-[#9E2891]" />
<span>
{stimulusKindsCount} stimulus kinds · {responseKindsCount} response kinds
</span>
</div>
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200">
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200 gap-2">
<Badge
className={cn(
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
statusColors[status],
isActive ? "bg-[#F0FDF4] text-[#16A34A]" : "bg-grayScale-50 text-grayScale-600",
)}
>
{status}
{statusLabel}
</Badge>
<div className="flex items-center gap-5 transition-opacity">
<button className="text-grayScale-500/70 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="text-grayScale-500/70 transition-all">
<Trash2 className="h-5 w-5" />
</button>
<div className="flex items-center gap-1">
{onEdit ? (
<Button type="button" variant="ghost" size="sm" className="h-9 w-9 p-0" onClick={onEdit} aria-label="Edit">
<Edit2 className="h-4 w-4 text-grayScale-500" />
</Button>
) : null}
{onDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
disabled={deleteDisabled}
onClick={onDelete}
aria-label="Delete"
>
<Trash2 className="h-4 w-4 text-grayScale-500 disabled:opacity-30" />
</Button>
) : null}
</div>
</div>
</div>
</Card>
);
)
}

View File

@ -0,0 +1,47 @@
import { Check } from "lucide-react"
import { cn } from "../../../../lib/utils"
import type { LucideIcon } from "lucide-react"
interface ComponentKindCardProps {
label: string
Icon: LucideIcon
selected: boolean
onClick: () => void
}
export function ComponentKindCard({ label, Icon, selected, onClick }: ComponentKindCardProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex flex-col items-start justify-between p-4 min-h-[132px] rounded-[16px] border text-left transition-all group relative",
selected
? "border-[#9E2891] bg-white shadow-[0_4px_12px_rgba(158,40,145,0.08)] ring-1 ring-[#9E2891]"
: "border-grayScale-200 bg-white hover:border-grayScale-300 hover:bg-grayScale-50/80",
)}
>
<div className="w-full flex items-start justify-between gap-2">
<div
className={cn(
"h-12 w-12 rounded-xl flex items-center justify-center shrink-0 transition-colors",
selected ? "bg-[#9E2891] text-white" : "bg-[#F1F5F9] text-grayScale-600 group-hover:bg-grayScale-100",
)}
>
<Icon className="h-6 w-6" />
</div>
<div
className={cn(
"h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all",
selected ? "border-[#9E2891] bg-white" : "border-grayScale-300 bg-white",
)}
>
{selected ? <Check className="h-3.5 w-3.5 text-[#9E2891] stroke-[3]" /> : null}
</div>
</div>
<span className={cn("text-[15px] font-bold leading-tight mt-2", selected ? "text-grayScale-900" : "text-grayScale-800")}>
{label}
</span>
</button>
)
}

View File

@ -1,150 +1,118 @@
import { useState } from "react";
import { X, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select";
import { Badge } from "../../../../components/ui/badge";
import { ArrowRight } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeBasicInfoStepProps {
onNext: () => void;
draft: QuestionTypeDefinitionCreatePayload
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
errors: FieldErrorMap
onNext: () => void
/** When editing an existing definition, the key is immutable on the server */
keyReadOnly?: boolean
}
export function QuestionTypeBasicInfoStep({
draft,
setDraft,
errors,
onNext,
keyReadOnly,
}: QuestionTypeBasicInfoStepProps) {
const [selectedChips, setSelectedChips] = useState([
"Multiple Choice",
"Sentence Completion",
]);
const suggestions = ["Matching Headings", "True/False/NG"];
const removeChip = (chip: string) => {
setSelectedChips(selectedChips.filter((c) => c !== chip));
};
const addChip = (chip: string) => {
if (!selectedChips.includes(chip)) {
setSelectedChips([...selectedChips, chip]);
}
};
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">
STEP 1: Basic Info
</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
<p className="text-grayScale-500 font-medium mt-1">
Define what this question type is and where it applies.
Set the reusable key, display name, and status. On the next step you will pick stimulus and response
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
</p>
</div>
<div className="p-10 space-y-10">
{/* Top Row: Course Type & Skill Category */}
<div className="grid grid-cols-2 gap-10">
<div className="space-y-3">
<div className="p-10 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Course Type <span className="text-red-500">*</span>
Key <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Select an exam type</option>
<option>IELTS</option>
<option>Duolingo</option>
<option>TOEFL</option>
</Select>
<p className="text-grayScale-400 text-[13px] font-medium leading-relaxed">
The core framework for the practice test.
<Input
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] disabled:opacity-70"
placeholder="e.g. dynamic_visual_mcq_001"
value={draft.key}
onChange={(e) => setDraft((d) => ({ ...d, key: e.target.value }))}
readOnly={keyReadOnly}
disabled={keyReadOnly}
/>
{errors.key ? <p className="text-sm text-red-600">{errors.key}</p> : null}
<p className="text-grayScale-400 text-[13px] font-medium">
{keyReadOnly
? "Key cannot be changed when editing an existing definition."
: "Unique slug-like identifier stored on the definition."}
</p>
</div>
<div className="space-y-3">
<div className="space-y-2">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Skill Category <span className="text-red-500">*</span>
Display name <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Select a skill</option>
<option>Speaking</option>
<option>Writing</option>
<option>Listening</option>
<option>Reading</option>
</Select>
</div>
</div>
{/* Question Type */}
<div className="space-y-3">
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Type <span className="text-red-500">*</span>
</label>
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
<option>Single Format</option>
<option>Mixed Format</option>
</Select>
</div>
{/* Question Types Chip Input */}
<div className="space-y-3">
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Types <span className="text-red-500">*</span>
</label>
<div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
{selectedChips.map((chip) => (
<Badge
key={chip}
className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
>
{chip}
<button
onClick={() => removeChip(chip)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
))}
<input
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-0 text-[14px] font-medium text-grayScale-900 px-3 placeholder:text-grayScale-400"
placeholder="Add question types..."
<Input
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
placeholder="e.g. Speak About the Photo"
value={draft.display_name}
onChange={(e) => setDraft((d) => ({ ...d, display_name: e.target.value }))}
/>
{errors.display_name ? <p className="text-sm text-red-600">{errors.display_name}</p> : null}
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<span className="text-[13px] font-medium text-grayScale-500">
Suggestions:
</span>
<div className="flex items-center gap-2">
{suggestions.map((s) => (
<button
key={s}
onClick={() => addChip(s)}
className="px-3 py-1.5 rounded-[6px] border border-grayScale-300 text-[13px] font-medium text-grayScale-600 hover:bg-grayScale-50 hover:text-[#9E2891] hover:border-[#9E2891]/20 transition-all"
>
{s}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-[14px] font-medium text-grayScale-700">Description</label>
<Textarea
className="min-h-[100px] rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
placeholder="Optional description for admins"
value={draft.description ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
/>
</div>
<div className="space-y-2 max-w-xs">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Status <span className="text-red-500">*</span>
</label>
<Select
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]"
value={draft.status}
onChange={(e) =>
setDraft((d) => ({
...d,
status: e.target.value === "INACTIVE" ? "INACTIVE" : "ACTIVE",
}))
}
>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive (draft)</option>
</Select>
</div>
</div>
{/* Footer */}
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
>
Cancel
</Button>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-end bg-[#F8FAFC]">
<Button
type="button"
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
>
Next: Structure
Next: Input and answer types
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
);
)
}

View File

@ -0,0 +1,175 @@
import { useState } from "react"
import { ArrowLeft, Loader2 } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import {
createQuestionTypeDefinition,
extractDefinitionMutationId,
updateQuestionTypeDefinition,
validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload
onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null
}
export function QuestionTypeReviewPublishStep({
draft,
onBack,
editDefinitionId,
}: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft)
const submit = async (status: "ACTIVE" | "INACTIVE") => {
const body = { ...payload, status }
setSubmitting(true)
try {
if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Question type definition updated", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(
res.data?.message ?? "Definition may not have been created: response did not include an id.",
)
return
}
toast.success(res.data?.message || "Question type definition created", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
const msg =
err.response?.data?.message ||
(e instanceof Error ? e.message : isEdit ? "Update failed" : "Create failed")
const detail = err.response?.data?.error
toast.error(String(msg), { description: detail ? String(detail) : undefined })
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review & publish</h2>
<p className="text-grayScale-500 font-medium mt-1">
{isEdit ? (
<>
Confirm changes, then update via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">
PUT /questions/type-definitions/{editDefinitionId}
</code>
.
</>
) : (
<>
Confirm details, then create via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p>
</div>
<div className="p-10 space-y-6">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.display_name}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Description</dt>
<dd className="font-medium text-grayScale-800 mt-1">{payload.description || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt>
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_schema.length}</dd>
</div>
</dl>
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="button"
variant="outline"
className="h-11"
disabled={submitting}
onClick={() => void submit("INACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as inactive" : "Save as inactive (draft)"}
</Button>
<Button
type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting}
onClick={() => void submit("ACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as active" : "Create as active"}
</Button>
</div>
</div>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-start bg-[#F8FAFC]">
<Button
type="button"
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
onClick={onBack}
disabled={submitting}
>
<ArrowLeft className="h-4 w-4 mr-2 inline" />
Back
</Button>
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,119 @@
import { useState } from "react"
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload
onNext: () => void
onBack: () => void
}
export function QuestionTypeValidatePreviewStep({
draft,
onNext,
onBack,
}: QuestionTypeValidatePreviewStepProps) {
const [validating, setValidating] = useState(false)
const [serverOk, setServerOk] = useState<boolean | null>(null)
const [serverDetail, setServerDetail] = useState<string | null>(null)
const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2)
const runValidate = async () => {
setValidating(true)
setServerOk(null)
setServerDetail(null)
try {
const res = await validateQuestionTypeDefinition(payload)
if (!res.valid) {
setServerOk(false)
const detail = res.error || res.message || "Validation failed"
setServerDetail(detail)
toast.error(res.message || "Invalid question type definition", { description: res.error })
return
}
setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.")
toast.success(res.message || "Question type definition is valid")
} finally {
setValidating(false)
}
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2>
<p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
. Uses the same JSON as create; validity comes from <code className="text-xs bg-grayScale-100 px-1 rounded">data.valid</code>{" "}
(not the envelope <code className="text-xs bg-grayScale-100 px-1 rounded">success</code> flag).
</p>
</div>
<div className="p-10 space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runValidate}
disabled={validating}
className="h-10"
>
{validating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validating
</>
) : (
"Validate with server"
)}
</Button>
{serverOk === true ? (
<span className="flex items-center gap-1.5 text-sm font-medium text-green-700">
<CheckCircle2 className="h-4 w-4" />
{serverDetail}
</span>
) : null}
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</span> : null}
</div>
<div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[420px] overflow-y-auto">
{json}
</pre>
</div>
</div>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button
type="button"
variant="outline"
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
onClick={onBack}
>
<ArrowLeft className="h-4 w-4 mr-2 inline" />
Back
</Button>
<Button
type="button"
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
>
Next: Review
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,194 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
type Side = "stimulus" | "response"
interface SchemaBuilderSectionProps {
title: string
side: Side
allowedKinds: string[]
catalogKinds: string[]
rows: DynamicElementDefinition[]
onChange: (rows: DynamicElementDefinition[]) => void
error?: string
rowErrors?: Record<number, string>
}
function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
const first = allowedKinds[0] ?? ""
return {
id: "",
kind: first,
label: "",
required: true,
config: undefined,
}
}
export function SchemaBuilderSection({
title,
side,
allowedKinds,
catalogKinds,
rows,
onChange,
error,
rowErrors,
}: SchemaBuilderSectionProps) {
const kindOptions =
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
onChange(next)
}
const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim()
if (!trimmed) {
updateRow(index, { config: undefined })
return
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
updateRow(index, { config: parsed })
}
} catch {
/* keep previous config; user can fix JSON */
}
}
const removeRow = (index: number) => {
onChange(rows.filter((_, i) => i !== index))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Each row defines one element in the{" "}
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
config).
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onChange([...rows, emptyRow(kindOptions)])}
disabled={!kindOptions.length}
>
<Plus className="h-4 w-4 mr-1.5" />
Add row
</Button>
</div>
{error ? <p className="text-sm font-medium text-red-600">{error}</p> : null}
{!kindOptions.length ? (
<p className="text-sm text-grayScale-500 rounded-lg border border-dashed border-grayScale-200 p-4">
Select component kinds in the previous step (or wait for the catalog to load) before building the{" "}
{side} schema.
</p>
) : null}
<div className="space-y-3">
{rows.map((row, index) => (
<div
key={`${side}-${index}`}
className="rounded-xl border border-grayScale-200 bg-[#F8FAFC] p-4 space-y-3"
>
<div className="flex items-start justify-between gap-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
Row {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-grayScale-500 hover:text-red-600"
onClick={() => removeRow(index)}
aria-label="Remove row"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">
Element id <span className="text-red-500">*</span>
</label>
<Input
value={row.id}
onChange={(e) => updateRow(index, { id: e.target.value })}
placeholder="e.g. prompt"
className="h-10 bg-white"
/>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">
Kind <span className="text-red-500">*</span>
</label>
<Select
className="h-10 bg-white"
value={row.kind}
onChange={(e) => updateRow(index, { kind: e.target.value })}
>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Label</label>
<Input
value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label"
className="h-10 bg-white"
/>
</div>
<label className="flex items-center gap-2 pt-6 md:pt-8">
<input
type="checkbox"
checked={row.required}
onChange={(e) => updateRow(index, { required: e.target.checked })}
className="rounded border-grayScale-300"
/>
<span className="text-[13px] font-medium text-grayScale-700">Required</span>
</label>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Config (JSON object)</label>
<Textarea
className="min-h-[72px] font-mono text-[13px] bg-white"
placeholder='{"max_length": 1000}'
defaultValue={row.config ? JSON.stringify(row.config, null, 2) : ""}
key={`${side}-cfg-${index}-${rows.length}`}
onBlur={(e) => commitConfigString(index, e.target.value)}
/>
<p className="text-[11px] text-grayScale-400">Tab out of the field to apply JSON config.</p>
</div>
{rowErrors?.[index] ? <p className="text-sm text-red-600">{rowErrors[index]}</p> : null}
</div>
))}
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,144 @@
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types"
export type FieldErrorMap = Record<string, string>
function uniqueStrings(values: string[]): boolean {
return new Set(values).size === values.length
}
function idsUniqueAndNonEmpty(rows: DynamicElementDefinition[], prefix: string, errors: FieldErrorMap) {
const ids = rows.map((r) => r.id?.trim()).filter(Boolean) as string[]
if (ids.length !== rows.length) {
errors[`${prefix}_ids`] = "Each schema row needs a non-empty id."
return
}
if (!uniqueStrings(ids)) {
errors[`${prefix}_ids`] = "Schema ids must be unique within this section."
}
}
export function validateDefinitionKinds(
payload: {
stimulus_component_kinds: string[]
response_component_kinds: string[]
},
catalog?: QuestionComponentCatalog | null,
): FieldErrorMap {
const errors: FieldErrorMap = {}
const { stimulus_component_kinds: sk, response_component_kinds: rk } = payload
if (!sk.length) errors.stimulus_kinds = "Select at least one stimulus component kind."
if (!rk.length) errors.response_kinds = "Select at least one response component kind."
if (sk.length && !uniqueStrings(sk)) errors.stimulus_kinds = "Stimulus kinds must be unique (no duplicates)."
if (rk.length && !uniqueStrings(rk)) errors.response_kinds = "Response kinds must be unique (no duplicates)."
if (rk.length === 1 && rk[0] === "ANSWER_TIMER") {
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
}
if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds)
if (sCat.size > 0 && sk.length) {
const invalid = sk.filter((k) => !sCat.has(k))
if (invalid.length) {
const msg = `Not in stimulus catalog: ${invalid.join(", ")}.`
errors.stimulus_kinds = errors.stimulus_kinds ? `${errors.stimulus_kinds} ${msg}` : msg
}
}
if (rCat.size > 0 && rk.length) {
const invalid = rk.filter((k) => !rCat.has(k))
if (invalid.length) {
const msg = `Not in response catalog: ${invalid.join(", ")}.`
errors.response_kinds = errors.response_kinds ? `${errors.response_kinds} ${msg}` : msg
}
}
}
return errors
}
export function validateDefinitionSchemas(
payload: Pick<
QuestionTypeDefinitionCreatePayload,
"stimulus_schema" | "response_schema" | "stimulus_component_kinds" | "response_component_kinds"
>,
catalog: { stimulus: Set<string>; response: Set<string> },
): FieldErrorMap {
const errors: FieldErrorMap = {}
const validateSide = (
rows: DynamicElementDefinition[],
allowed: string[],
side: "stimulus" | "response",
) => {
const prefix = side
if (!rows.length) {
errors[`${prefix}_schema`] = `Add at least one ${side} schema row.`
return
}
idsUniqueAndNonEmpty(rows, prefix, errors)
const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => {
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
else if (allowedSet.size && !allowedSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
}
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
}
})
}
validateSide(payload.stimulus_schema, payload.stimulus_component_kinds, "stimulus")
validateSide(payload.response_schema, payload.response_component_kinds, "response")
return errors
}
export function validateDefinitionBasic(payload: {
key: string
display_name: string
}): FieldErrorMap {
const errors: FieldErrorMap = {}
if (!payload.key?.trim()) errors.key = "Key is required (slug-like, unique)."
if (!payload.display_name?.trim()) errors.display_name = "Display name is required."
if (payload.key && !/^[a-z0-9][a-z0-9_-]*$/i.test(payload.key.trim())) {
errors.key = "Use letters, numbers, underscores, or hyphens; start with a letter or number."
}
return errors
}
export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload {
return {
...draft,
key: draft.key.trim(),
display_name: draft.display_name.trim(),
description: draft.description?.trim() || null,
stimulus_component_kinds: [...draft.stimulus_component_kinds],
response_component_kinds: [...draft.response_component_kinds],
stimulus_schema: draft.stimulus_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
config: r.config && Object.keys(r.config).length ? r.config : undefined,
})),
response_schema: draft.response_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
config: r.config && Object.keys(r.config).length ? r.config : undefined,
})),
}
}

View File

@ -1,3 +1,5 @@
import type { DynamicQuestionPayload } from "./questionTypeDefinition.types"
export interface CourseCategory {
id: number
name: string
@ -1043,6 +1045,10 @@ export interface CreateQuestionRequest {
sample_answer_voice_prompt?: string
audio_correct_answer_text?: string
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
/** Required for `DYNAMIC` questions — links to `/questions/type-definitions/:id` */
question_type_definition_id?: number
/** Required for `DYNAMIC` — stimulus/response element instances per definition schema */
dynamic_payload?: DynamicQuestionPayload
}
export interface CreateQuestionResponse {
@ -1076,6 +1082,8 @@ export interface QuestionDetail {
voice_prompt?: string | null
sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null
question_type_definition_id?: number | null
dynamic_payload?: DynamicQuestionPayload | null
}
export interface GetQuestionDetailResponse {

View File

@ -0,0 +1,58 @@
/** Dynamic question type definition builder (admin) — aligns with POST /questions/type-definitions */
/** GET /questions/component-catalog — `data` object shape */
export interface QuestionComponentCatalog {
stimulus_component_kinds: string[]
response_component_kinds: string[]
}
export type DefinitionStatus = "ACTIVE" | "INACTIVE"
export interface DynamicElementDefinition {
id: string
kind: string
label?: string
required: boolean
config?: Record<string, unknown>
}
export interface QuestionTypeDefinitionCreatePayload {
key: string
display_name: string
description?: string | null
stimulus_component_kinds: string[]
response_component_kinds: string[]
stimulus_schema: DynamicElementDefinition[]
response_schema: DynamicElementDefinition[]
status: DefinitionStatus
}
/** PUT /questions/type-definitions/:id — any subset of create fields */
export type QuestionTypeDefinitionUpdatePayload = Partial<QuestionTypeDefinitionCreatePayload>
/** POST /questions/validate-question-type-definition — body is often kinds-only; full create shape is also accepted */
export type QuestionTypeDefinitionValidatePayload = Partial<QuestionTypeDefinitionCreatePayload>
/** Parsed outcome (do not rely on envelope `success`; it may be false when `data.valid` is true) */
export type ValidateQuestionTypeDefinitionResult =
| { valid: true; message?: string }
| { valid: false; message?: string; error?: string }
export interface QuestionTypeDefinition extends QuestionTypeDefinitionCreatePayload {
id: number
is_system?: boolean
created_at?: string
updated_at?: string
}
export interface DynamicElementInstance {
id: string
kind: string
value?: unknown
meta?: Record<string, unknown>
}
export interface DynamicQuestionPayload {
stimulus: DynamicElementInstance[]
response: DynamicElementInstance[]
}