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" path="/new-content/question-types"
element={<QuestionTypeLibraryPage />} element={<QuestionTypeLibraryPage />}
/> />
<Route
path="/new-content/question-types/:definitionId/edit"
element={<CreateQuestionTypeFlow />}
/>
<Route <Route
path="/new-content/question-types/create" path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />} element={<CreateQuestionTypeFlow />}

View File

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

View File

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

View File

@ -8,18 +8,11 @@ import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api" 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 Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE" type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question { interface Question {
id?: number id?: number
question: string question: string
@ -34,9 +27,6 @@ interface Question {
voicePrompt: string voicePrompt: string
sampleAnswerVoicePrompt: string sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
} }
const initialForm: Question = { const initialForm: Question = {
@ -52,8 +42,6 @@ const initialForm: Question = {
voicePrompt: "", voicePrompt: "",
sampleAnswerVoicePrompt: "", sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
} }
export function AddQuestionPage() { export function AddQuestionPage() {
@ -64,7 +52,6 @@ export function AddQuestionPage() {
const [formData, setFormData] = useState<Question>(initialForm) const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
useEffect(() => { useEffect(() => {
const loadQuestion = async () => { const loadQuestion = async () => {
@ -77,8 +64,7 @@ export function AddQuestionPage() {
q.question_type === "MCQ" || q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" || q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" || q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO" || q.question_type === "AUDIO"
q.question_type === "DYNAMIC"
? q.question_type ? q.question_type
: "MCQ" : "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0 const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -114,14 +100,6 @@ export function AddQuestionPage() {
voicePrompt: q.voice_prompt || "", voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "", sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "", 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) { } catch (error) {
console.error("Failed to load question:", error) console.error("Failed to load question:", error)
@ -133,22 +111,6 @@ export function AddQuestionPage() {
loadQuestion() loadQuestion()
}, [isEditing, id]) }, [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) => { const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => { setFormData((prev) => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {
@ -158,15 +120,6 @@ export function AddQuestionPage() {
options: ["True", "False"], options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "", 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") { } else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return { return {
...prev, ...prev,
@ -247,27 +200,6 @@ export function AddQuestionPage() {
}) })
return 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) setSubmitting(true)
@ -289,18 +221,6 @@ export function AddQuestionPage() {
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const }, { acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
] ]
: undefined : 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 = { const payload = {
question_text: formData.question, question_text: formData.question,
question_type: formData.type, question_type: formData.type,
@ -316,12 +236,6 @@ export function AddQuestionPage() {
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined, formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined, formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
...(formData.type === "DYNAMIC" && dynamicPayload
? {
question_type_definition_id: Number(formData.questionTypeDefinitionId),
dynamic_payload: dynamicPayload,
}
: {}),
} }
if (isEditing && id) { if (isEditing && id) {
await updateQuestion(Number(id), payload) await updateQuestion(Number(id), payload)
@ -389,58 +303,15 @@ export function AddQuestionPage() {
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option> <option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option> <option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </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" /> <hr className="border-grayScale-100" />
{/* Question Text */} {/* Question Text */}
<div> <div>
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"} Question
</label> </label>
<Textarea <Textarea
id="question" id="question"
@ -497,7 +368,6 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Correct Answer */} {/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"} {formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
@ -533,7 +403,6 @@ export function AddQuestionPage() {
/> />
)} )}
</div> </div>
)}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />

View File

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

View File

@ -39,7 +39,8 @@ import type {
import { AddModuleModal } from "./components/AddModuleModal"; import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; 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 { function isLikelyImageUrl(src: string): boolean {
const t = src.trim(); const t = src.trim();
@ -53,8 +54,7 @@ function isLikelyImageUrl(src: string): boolean {
function isSignedMinioUrl(src: string): boolean { function isSignedMinioUrl(src: string): boolean {
const value = src.trim(); const value = src.trim();
if (!value.startsWith("http://") && !value.startsWith("https://")) if (!value.startsWith("http://") && !value.startsWith("https://")) return false;
return false;
try { try {
const url = new URL(value); const url = new URL(value);
return url.searchParams.has("X-Amz-Signature"); 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 = const displayDescription =
course?.description?.trim() || course?.description?.trim() ||
(!loading && !course (!loading && !course
@ -501,7 +502,9 @@ export function CourseDetailPage() {
key={module.id} 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" 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 <Button
type="button" type="button"
variant="secondary" variant="secondary"
@ -533,10 +536,8 @@ export function CourseDetailPage() {
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]"> <h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name} {module.name}
</h3> </h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3"> <p className="text-[12px] font-medium leading-snug text-grayScale-400">
{module.description?.trim() {module.description?.trim() ? module.description : "—"}
? module.description
: "—"}
</p> </p>
</div> </div>
</div> </div>
@ -551,8 +552,7 @@ export function CourseDetailPage() {
{ {
state: { state: {
moduleName: module.name, moduleName: module.name,
moduleDescription: moduleDescription: module.description?.trim() ?? "",
module.description?.trim() ?? "",
}, },
}, },
) )

View File

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

View File

@ -1,116 +1,50 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom" import { Link } from "react-router-dom";
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react" import { ArrowLeft, Plus, Search } from "lucide-react";
import { toast } from "sonner" import { Button } from "../../components/ui/button";
import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input";
import { Input } from "../../components/ui/input" import { Select } from "../../components/ui/select";
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card";
import { import { cn } from "../../lib/utils";
Dialog, import { QuestionTypeCard } from "./components/QuestionTypeCard";
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
getQuestionTypeDefinitions,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
export function QuestionTypeLibraryPage() { export function QuestionTypeLibraryPage() {
const navigate = useNavigate() const [activeTab, setActiveTab] = useState("All");
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const [loading, setLoading] = useState(true) const questionTypes = [
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([]) {
const [query, setQuery] = useState("") title: "Describe a Photo",
const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All") exam: "DUOLINGO" as const,
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null) skill: "Speaking" as const,
const [deleteSubmitting, setDeleteSubmitting] = useState(false) variations: 12,
status: "Published" as const,
const load = useCallback(async () => { },
setLoading(true) {
try { title: "Write About the Topic",
const rows = await getQuestionTypeDefinitions({ include_system: true }) exam: "DUOLINGO" as const,
setDefinitions(Array.isArray(rows) ? rows : []) skill: "Writing" as const,
} catch (e) { variations: 12,
console.error(e) status: "Published" as const,
toast.error("Failed to load question type definitions") },
setDefinitions([]) {
} finally { title: "Fill in the Blanks",
setLoading(false) exam: "IELTS" as const,
} skill: "Writing" as const,
}, []) variations: 12,
status: "Published" as const,
useEffect(() => { },
void load() {
}, [load]) title: "Describe a Photo",
exam: "DUOLINGO" as const,
useEffect(() => { skill: "Speaking" as const,
if (createdId) { variations: 12,
toast.success("Definition created", { description: `Id ${createdId}` }) status: "Published" as const,
} },
}, [createdId]) ];
useEffect(() => {
if (updatedId) {
toast.success("Definition updated", { description: `Id ${updatedId}` })
}
}, [updatedId])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return definitions.filter((d) => {
if (activeTab !== "All") {
const st = (d.status || "").toString().toUpperCase()
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
}
if (!q) return true
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query, activeTab])
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
toast.error("System definitions cannot be deleted.")
return
}
setDefinitionPendingDelete(row)
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open && !deleteSubmitting) setDefinitionPendingDelete(null)
}
const handleConfirmDeleteDefinition = async () => {
const row = definitionPendingDelete
if (!row) return
setDeleteSubmitting(true)
try {
await deleteQuestionTypeDefinition(row.id)
toast.success("Definition deleted")
setDefinitionPendingDelete(null)
void load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(String(err.response?.data?.message || "Delete failed"))
} finally {
setDeleteSubmitting(false)
}
}
return ( return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20"> <div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6"> <div className="space-y-6">
<Link <Link
to="/new-content/courses" to="/new-content/courses"
@ -120,43 +54,54 @@ export function QuestionTypeLibraryPage() {
Back to Courses Back to Courses
</Link> </Link>
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1> <h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl"> Question Type Library
Reusable dynamic question type templates from{" "} </h1>
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them <p className="text-grayScale-500 text-[16px] font-medium">
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Create and manage reusable question structures for practices and
assessments.
</p> </p>
</div> </div>
<Link to="/new-content/question-types/create"> <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"> <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" /> <Plus className="h-5 w-5" />
Create definition Create Question Type
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Control Bar */}
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6"> <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"> <div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input <Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm" 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…" placeholder="Search by practice name, ID, or keywords..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/> />
</div> </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>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span> <span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => ( STATUS:
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<button <button
key={tab} key={tab}
type="button"
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={cn( className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all", "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", : "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)} )}
> >
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"} {tab}
</button> </button>
))} ))}
</div> </div>
</Card> </Card>
{loading ? ( {/* Grid of Cards */}
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p> <div className="grid grid-cols-4 gap-6">
) : filtered.length === 0 ? ( {questionTypes.map((qt, index) => (
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl"> <QuestionTypeCard key={index} {...qt} />
<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> </div>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</div>
)}
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
<Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
Delete question type definition?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This removes the template from the library. Existing questions that reference it may be affected. This
action cannot be undone.
</DialogDescription>
</DialogHeader>
{definitionPendingDelete ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3 space-y-1">
<p className="text-sm font-semibold text-grayScale-900">{definitionPendingDelete.display_name}</p>
<p className="text-xs font-mono text-grayScale-500 break-all">
#{definitionPendingDelete.id} · {definitionPendingDelete.key}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
className="border-grayScale-200"
disabled={deleteSubmitting}
onClick={() => setDefinitionPendingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteSubmitting}
className="gap-2"
onClick={() => void handleConfirmDeleteDefinition()}
>
{deleteSubmitting ? <SpinnerIcon className="h-4 w-4" /> : <Trash2 className="h-4 w-4" aria-hidden />}
{deleteSubmitting ? "Deleting…" : "Delete definition"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </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 { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react";
import { Badge } from "../../../components/ui/badge" import { Badge } from "../../../components/ui/badge";
import { Card } from "../../../components/ui/card" import { Card } from "../../../components/ui/card";
import { Button } from "../../../components/ui/button" import { cn } from "../../../lib/utils";
import { cn } from "../../../lib/utils"
export interface QuestionTypeDefinitionCardModel { interface QuestionTypeCardProps {
id: number title: string;
definitionKey: string exam: "DUOLINGO" | "IELTS" | "TOEFL";
display_name: string skill: "Speaking" | "Writing" | "Listening" | "Reading";
status?: string variations: number;
is_system?: boolean status: "Published" | "Draft" | "Archived";
stimulusKindsCount: number
responseKindsCount: number
onEdit?: () => void
onDelete?: () => void
deleteDisabled?: boolean
} }
export function QuestionTypeCard({ export function QuestionTypeCard({
id, title,
definitionKey, exam,
display_name, skill,
variations,
status, status,
is_system, }: QuestionTypeCardProps) {
stimulusKindsCount, const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard;
responseKindsCount,
onEdit, const examColors = {
onDelete, DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent",
deleteDisabled, IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent",
}: QuestionTypeDefinitionCardModel) { TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent",
const statusLabel = (status || "—").toString() };
const isActive = statusLabel.toUpperCase() === "ACTIVE"
const statusColors = {
Published: "bg-[#F0FDF4] text-[#16A34A]",
Draft: "bg-grayScale-50 text-grayScale-500",
Archived: "bg-red-50 text-red-500",
};
return ( return (
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300"> <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="px-4 py-6 space-y-8">
<div className="flex items-start justify-between gap-2"> <h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]">
<h3 className="text-[18px] font-bold text-grayScale-900 leading-[1.2]">{display_name}</h3> {title}
{is_system ? ( </h3>
<Badge className="shrink-0 border-none bg-violet-100 text-violet-800 flex items-center gap-1">
<Shield className="h-3 w-3" /> <div className="flex items-center justify-between">
System <Badge
</Badge> className={cn(
) : null} "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> </div>
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p> <div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
<Layers className="h-[16px] w-[16px]" />
<div className="flex flex-wrap items-center gap-2 text-grayScale-700 font-medium text-[14px]"> {variations} Variations
<Layers className="h-4 w-4 text-[#9E2891]" />
<span>
{stimulusKindsCount} stimulus kinds · {responseKindsCount} response kinds
</span>
</div> </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 <Badge
className={cn( className={cn(
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none", "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> </Badge>
<div className="flex items-center gap-1"> <div className="flex items-center gap-5 transition-opacity">
{onEdit ? ( <button className="text-grayScale-500/70 transition-all">
<Button type="button" variant="ghost" size="sm" className="h-9 w-9 p-0" onClick={onEdit} aria-label="Edit"> <Edit2 className="h-5 w-5" />
<Edit2 className="h-4 w-4 text-grayScale-500" /> </button>
</Button> <button className="text-grayScale-500/70 transition-all">
) : null} <Trash2 className="h-5 w-5" />
{onDelete ? ( </button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
disabled={deleteDisabled}
onClick={onDelete}
aria-label="Delete"
>
<Trash2 className="h-4 w-4 text-grayScale-500 disabled:opacity-30" />
</Button>
) : null}
</div> </div>
</div> </div>
</div> </div>
</Card> </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 { useState } from "react";
import { Button } from "../../../../components/ui/button" import { X, ArrowRight } from "lucide-react";
import { Card } from "../../../../components/ui/card" import { Button } from "../../../../components/ui/button";
import { Input } from "../../../../components/ui/input" import { Card } from "../../../../components/ui/card";
import { Textarea } from "../../../../components/ui/textarea" import { Select } from "../../../../components/ui/select";
import { Select } from "../../../../components/ui/select" import { Badge } from "../../../../components/ui/badge";
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
interface QuestionTypeBasicInfoStepProps { interface QuestionTypeBasicInfoStepProps {
draft: QuestionTypeDefinitionCreatePayload onNext: () => void;
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
errors: FieldErrorMap
onNext: () => void
/** When editing an existing definition, the key is immutable on the server */
keyReadOnly?: boolean
} }
export function QuestionTypeBasicInfoStep({ export function QuestionTypeBasicInfoStep({
draft,
setDraft,
errors,
onNext, onNext,
keyReadOnly,
}: QuestionTypeBasicInfoStepProps) { }: 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 ( return (
<div className="space-y-8 pb-32"> <div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white"> <Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 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"> <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 Define what this question type is and where it applies.
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
</p> </p>
</div> </div>
<div className="p-10 space-y-8"> <div className="p-10 space-y-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* Top Row: Course Type & Skill Category */}
<div className="space-y-2"> <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"> <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> </label>
<Input <Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] disabled:opacity-70" <option>Select an exam type</option>
placeholder="e.g. dynamic_visual_mcq_001" <option>IELTS</option>
value={draft.key} <option>Duolingo</option>
onChange={(e) => setDraft((d) => ({ ...d, key: e.target.value }))} <option>TOEFL</option>
readOnly={keyReadOnly} </Select>
disabled={keyReadOnly} <p className="text-grayScale-400 text-[13px] font-medium leading-relaxed">
/> The core framework for the practice test.
{errors.key ? <p className="text-sm text-red-600">{errors.key}</p> : null}
<p className="text-grayScale-400 text-[13px] font-medium">
{keyReadOnly
? "Key cannot be changed when editing an existing definition."
: "Unique slug-like identifier stored on the definition."}
</p> </p>
</div> </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"> <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> </label>
<Input <Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]" <option>Select a skill</option>
placeholder="e.g. Speak About the Photo" <option>Speaking</option>
value={draft.display_name} <option>Writing</option>
onChange={(e) => setDraft((d) => ({ ...d, display_name: e.target.value }))} <option>Listening</option>
/> <option>Reading</option>
{errors.display_name ? <p className="text-sm text-red-600">{errors.display_name}</p> : null} </Select>
</div> </div>
</div> </div>
<div className="space-y-2"> {/* Question Type */}
<label className="text-[14px] font-medium text-grayScale-700">Description</label> <div className="space-y-3">
<Textarea <label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
className="min-h-[100px] rounded-[12px] border-grayScale-300 bg-[#F8FAFC]" Question Type <span className="text-red-500">*</span>
placeholder="Optional description for admins" </label>
value={draft.description ?? ""} <Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))} <option>Single Format</option>
/> <option>Mixed Format</option>
</Select>
</div> </div>
<div className="space-y-2 max-w-xs"> {/* Question Types Chip Input */}
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1"> <div className="space-y-3">
Status <span className="text-red-500">*</span> <label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
Question Types <span className="text-red-500">*</span>
</label> </label>
<Select <div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC]" {selectedChips.map((chip) => (
value={draft.status} <Badge
onChange={(e) => key={chip}
setDraft((d) => ({ className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
...d, >
status: e.target.value === "INACTIVE" ? "INACTIVE" : "ACTIVE", {chip}
})) <button
} onClick={() => removeChip(chip)}
> className="hover:text-red-500 transition-colors"
<option value="ACTIVE">Active</option> >
<option value="INACTIVE">Inactive (draft)</option> <X className="h-3.5 w-3.5" />
</Select> </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> </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 <Button
type="button"
onClick={onNext} 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" 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" /> <ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>
</Card> </Card>
</div> </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 { export interface CourseCategory {
id: number id: number
name: string name: string
@ -1045,10 +1043,6 @@ export interface CreateQuestionRequest {
sample_answer_voice_prompt?: string sample_answer_voice_prompt?: string
audio_correct_answer_text?: string audio_correct_answer_text?: string
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[] 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 { export interface CreateQuestionResponse {
@ -1082,8 +1076,6 @@ export interface QuestionDetail {
voice_prompt?: string | null voice_prompt?: string | null
sample_answer_voice_prompt?: string | null sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null audio_correct_answer_text?: string | null
question_type_definition_id?: number | null
dynamic_payload?: DynamicQuestionPayload | null
} }
export interface GetQuestionDetailResponse { 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[]
}