Compare commits

..

No commits in common. "457d09f02b44e63cbfbf1bfd69d5a111874e1c71" and "608d5bb45a77bb2622edd725df4d21e11557ba47" have entirely different histories.

21 changed files with 1653 additions and 3113 deletions

View File

@ -1,303 +0,0 @@
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,10 +170,6 @@ 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,7 +12,6 @@ import {
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
import { type ComponentType, useEffect, useState } from "react";
@ -40,7 +39,6 @@ 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 React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
Bell,
Eye,
@ -13,43 +13,21 @@ 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 =
| "subscription"
| "profile"
| "security"
| "notifications"
| "appearance";
type SettingsTab = "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 },
@ -70,14 +48,14 @@ function Toggle({
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
enabled ? "bg-brand-500" : "bg-grayScale-200",
"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"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
@ -90,20 +68,20 @@ function SettingRow({
description,
children,
}: {
icon: any;
icon: typeof User;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-800">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
<p className="text-sm font-medium text-grayScale-600">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
@ -111,143 +89,34 @@ function SettingRow({
);
}
// --- 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);
}
};
function LoadingSkeleton() {
return (
<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>
<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" />
))}
</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="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>
<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>
</DialogContent>
</Dialog>
</div>
</div>
</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);
@ -273,88 +142,79 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
};
return (
<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>
<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>
</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-[11px] font-bold uppercase tracking-wider text-grayScale-400">
First Name
</label>
<Input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="rounded-[6px]"
/>
<label className="text-xs font-medium text-grayScale-500">First Name</label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-1.5">
<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]"
/>
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<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]"
/>
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
</div>
</CardContent>
</Card>
<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>
<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>
</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-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Preferred Language
</label>
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="rounded-[6px]"
>
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
<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] rounded-[6px] font-bold"
>
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="h-4 w-4 mr-2" />
<Save className="h-4 w-4" />
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
@ -380,102 +240,96 @@ function SecurityTab() {
};
return (
<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>
<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>
</CardHeader>
<CardContent className="space-y-5 pb-6">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Current Password
</label>
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
<div className="relative">
<Input
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
className="rounded-[6px]"
/>
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
<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-[11px] font-bold uppercase tracking-wider text-grayScale-400">
New Password
</label>
<label className="text-xs font-medium text-grayScale-500">New Password</label>
<div className="relative">
<Input
type={showNew ? "text" : "password"}
placeholder="Enter new password"
className="rounded-[6px]"
/>
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
<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-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Confirm New Password
</label>
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
<div className="relative">
<Input
type={showConfirm ? "text" : "password"}
placeholder="Confirm new password"
className="rounded-[6px]"
/>
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
<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] rounded-[6px] font-bold"
>
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Lock className="h-4 w-4 mr-2" />
<Lock className="h-4 w-4" />
)}
{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>
);
}
@ -484,14 +338,20 @@ 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 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>
<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>
</CardHeader>
<CardContent className="space-y-1 pb-6">
<SettingRow
@ -499,32 +359,31 @@ 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 className="bg-grayScale-50" />
<Separator />
<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 className="bg-grayScale-50" />
<Separator />
<SettingRow
icon={Shield}
title="Login Alerts"
description="Get notified when someone logs into your account"
>
<Toggle
enabled={loginAlerts}
onToggle={() => setLoginAlerts(!loginAlerts)}
/>
<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)} />
</SettingRow>
</CardContent>
</Card>
@ -535,12 +394,17 @@ function AppearanceTab() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
return (
<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>
<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>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
@ -556,18 +420,16 @@ function AppearanceTab() {
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
theme === id
? "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",
? "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"
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[6px]",
theme === id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-400",
"flex h-10 w-10 items-center justify-center rounded-lg",
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
)}
>
<Icon className="h-5 w-5" />
@ -582,7 +444,7 @@ function AppearanceTab() {
}
export function SettingsPage() {
const [activeTab, setActiveTab] = useState<SettingsTab>("subscription");
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -602,27 +464,21 @@ export function SettingsPage() {
fetchProfile();
}, []);
if (loading) {
return (
<div className="flex h-[400px] items-center justify-center">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
</div>
);
}
if (loading) return <LoadingSkeleton />;
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<Card className="border-dashed border-grayScale-200 rounded-[6px]">
<Card className="border-dashed">
<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-bold tracking-tight text-grayScale-900">
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
{error || "Settings not available"}
</p>
<p className="mt-1 text-sm text-grayScale-500">
<p className="mt-1 text-sm text-grayScale-400">
Please check your connection and try again.
</p>
</div>
@ -633,23 +489,40 @@ export function SettingsPage() {
}
return (
<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
<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
</p>
</div>
<div className="flex flex-col gap-8">
{/* Content Area */}
<main className="min-h-[400px]">
{activeTab === "subscription" && <SubscriptionTab />}
</main>
{/* 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>
{/* Tab content */}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab />}
{activeTab === "notifications" && <NotificationsTab />}
{activeTab === "appearance" && <AppearanceTab />}
</div>
);
}

View File

@ -8,18 +8,11 @@ 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" | "DYNAMIC"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question {
id?: number
question: string
@ -34,9 +27,6 @@ interface Question {
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
}
const initialForm: Question = {
@ -52,8 +42,6 @@ const initialForm: Question = {
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
export function AddQuestionPage() {
@ -64,7 +52,6 @@ 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 () => {
@ -77,8 +64,7 @@ export function AddQuestionPage() {
q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO" ||
q.question_type === "DYNAMIC"
q.question_type === "AUDIO"
? q.question_type
: "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -114,14 +100,6 @@ 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)
@ -133,22 +111,6 @@ 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") {
@ -158,15 +120,6 @@ 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,
@ -247,27 +200,6 @@ 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)
@ -289,18 +221,6 @@ 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,
@ -316,12 +236,6 @@ 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)
@ -389,58 +303,15 @@ 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">
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"}
Question
</label>
<Textarea
id="question"
@ -497,7 +368,6 @@ 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"}
@ -533,7 +403,6 @@ export function AddQuestionPage() {
/>
)}
</div>
)}
<hr className="border-grayScale-100" />

View File

@ -1,11 +1,10 @@
import { Outlet } from "react-router-dom";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
import { Outlet } from "react-router-dom"
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 mb-8">
<div className="flex items-center gap-3">
<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]">
@ -16,11 +15,9 @@ export function ContentManagementLayout() {
</p>
</div>
</div>
<ContentHierarchyList />
</div>
<Outlet />
</div>
);
)
}

View File

@ -39,7 +39,8 @@ 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();
@ -53,8 +54,7 @@ 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,7 +308,8 @@ export function CourseDetailPage() {
}
};
const displayTitle = course?.name?.trim() || courseIdParam || "Course";
const displayTitle =
course?.name?.trim() || courseIdParam || "Course";
const displayDescription =
course?.description?.trim() ||
(!loading && !course
@ -501,7 +502,9 @@ 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"
@ -533,10 +536,8 @@ 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 line-clamp-3">
{module.description?.trim()
? module.description
: "—"}
<p className="text-[12px] font-medium leading-snug text-grayScale-400">
{module.description?.trim() ? module.description : "—"}
</p>
</div>
</div>
@ -551,8 +552,7 @@ export function CourseDetailPage() {
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
moduleDescription: module.description?.trim() ?? "",
},
},
)

View File

@ -1,279 +1,31 @@
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 })),
}
}
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";
export function CreateQuestionTypeFlow() {
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 navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
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 steps = [
"Basic Info",
"Input & Answer Configuration",
"Versions",
"Review & Publish",
];
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>
)
}
const handleNext = () =>
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
return (
<div className="min-h-screen pb-20 overflow-x-hidden">
<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">
{/* Header */}
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
<div className="max-w-[1440px] mx-auto py-6">
<div className="flex items-center justify-between mb-8">
<Link
to="/new-content/question-types"
@ -284,29 +36,16 @@ export function CreateQuestionTypeFlow() {
</Link>
</div>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
{isEdit ? "Edit question type definition" : "Create question type definition"}
Create Question Type
</h1>
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit ? (
<>
Update definition{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
<p className="text-grayScale-500 text-[14px] font-medium">
Create a new immersive practice session for students.
</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-4">
<Button
variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
@ -314,10 +53,7 @@ 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"
onClick={handleHeaderSaveDraft}
>
<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">
Save as Draft
</Button>
</div>
@ -329,38 +65,23 @@ export function CreateQuestionTypeFlow() {
</div>
</div>
<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}
/>
)}
{/* 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
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}
/>
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
)}
{currentStep === 3 && (
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
{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>
)}
</div>
</div>
)
);
}

View File

@ -1,116 +1,50 @@
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"
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";
export function QuestionTypeLibraryPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const [activeTab, setActiveTab] = useState("All");
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)
}
}
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,
},
];
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"
@ -120,43 +54,54 @@ export function QuestionTypeLibraryPage() {
Back to Courses
</Link>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
<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.
</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 definition
Create Question Type
</Button>
</Link>
</div>
</div>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="flex 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 display name, key, or id…"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by practice name, ID, or keywords..."
/>
</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 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) => (
<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) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
@ -165,82 +110,18 @@ export function QuestionTypeLibraryPage() {
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
>
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
{tab}
</button>
))}
</div>
</Card>
{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>
{/* Grid of Cards */}
<div className="grid grid-cols-4 gap-6">
{questionTypes.map((qt, index) => (
<QuestionTypeCard key={index} {...qt} />
))}
</div>
</div>
)
);
}

View File

@ -1,612 +0,0 @@
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,90 +1,83 @@
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"
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";
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
interface QuestionTypeCardProps {
title: string;
exam: "DUOLINGO" | "IELTS" | "TOEFL";
skill: "Speaking" | "Writing" | "Listening" | "Reading";
variations: number;
status: "Published" | "Draft" | "Archived";
}
export function QuestionTypeCard({
id,
definitionKey,
display_name,
title,
exam,
skill,
variations,
status,
is_system,
stimulusKindsCount,
responseKindsCount,
onEdit,
onDelete,
deleteDisabled,
}: QuestionTypeDefinitionCardModel) {
const statusLabel = (status || "—").toString()
const isActive = statusLabel.toUpperCase() === "ACTIVE"
}: 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",
};
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-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}
<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>
</div>
<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 className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
<Layers className="h-[16px] w-[16px]" />
{variations} Variations
</div>
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200 gap-2">
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200">
<Badge
className={cn(
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
isActive ? "bg-[#F0FDF4] text-[#16A34A]" : "bg-grayScale-50 text-grayScale-600",
statusColors[status],
)}
>
{statusLabel}
{status}
</Badge>
<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 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>
</div>
</div>
</Card>
)
);
}

View File

@ -1,47 +0,0 @@
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,118 +1,150 @@
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"
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";
interface QuestionTypeBasicInfoStepProps {
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
onNext: () => void;
}
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: Definition basics</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">
STEP 1: Basic Info
</h2>
<p className="text-grayScale-500 font-medium mt-1">
Set the reusable key, display name, and status. On the next step you will pick stimulus and response
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
Define what this question type is and where it applies.
</p>
</div>
<div className="p-10 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<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">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Key <span className="text-red-500">*</span>
Course Type <span className="text-red-500">*</span>
</label>
<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."}
<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.
</p>
</div>
<div className="space-y-2">
<div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
Display name <span className="text-red-500">*</span>
Skill Category <span className="text-red-500">*</span>
</label>
<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}
<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>
<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 }))}
/>
{/* 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>
<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>
{/* 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>
<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 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..."
/>
</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>
</div>
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-end bg-[#F8FAFC]">
{/* 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>
<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: Input and answer types
Next: Structure
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</Card>
</div>
)
);
}

View File

@ -1,175 +0,0 @@
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

@ -1,119 +0,0 @@
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

@ -1,194 +0,0 @@
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

@ -1,104 +0,0 @@
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

@ -1,144 +0,0 @@
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,5 +1,3 @@
import type { DynamicQuestionPayload } from "./questionTypeDefinition.types"
export interface CourseCategory {
id: number
name: string
@ -1045,10 +1043,6 @@ 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 {
@ -1082,8 +1076,6 @@ 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

@ -1,58 +0,0 @@
/** 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[]
}