feat(content): lesson practices page, dynamic question schema, and practice flow updates

- Add LessonPracticesPage with GET /lessons/:id/practices and polished UI
- Route and module lesson navigation; view practices icon on VideoCard hover
- Question type definitions API, DynamicSchemaSlotField, definition helpers
- AddPracticeFlow and practice steps; AddQuestionPage and PracticeQuestionEditorFields

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-13 09:30:53 -07:00
parent f1b6172f91
commit 2b556d9d09
15 changed files with 2598 additions and 704 deletions

View File

@ -217,7 +217,9 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
return { return {
id, id,
key: asStr(o.Key ?? o.key), key: asStr(o.Key ?? o.key),
display_name: asStr(o.DisplayName ?? o.display_name), display_name: asStr(
o.DisplayName ?? o.display_name ?? o.displayName ?? o.Display_Name,
),
description: (() => { description: (() => {
const d = o.Description ?? o.description const d = o.Description ?? o.description
if (d == null) return null if (d == null) return null
@ -235,6 +237,15 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
} }
} }
/** Label for selects: API `DisplayName` (stored as `display_name`), then key, then id. */
export function questionTypeDefinitionListLabel(def: QuestionTypeDefinition): string {
const name = def.display_name?.trim()
if (name) return name
const k = def.key?.trim()
if (k) return k
return `Type #${def.id}`
}
/** /**
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`). * Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
* Example update: `{ "data": { "id": 6 } }`. * Example update: `{ "data": { "id": 6 } }`.

View File

@ -18,6 +18,7 @@ import { NewContentPage } from "../pages/content-management/NewContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage"; import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage"; import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage"; import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage";
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage"; import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow"; import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow"; import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
@ -222,6 +223,10 @@ export function AppRoutes() {
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video" path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
element={<AddVideoFlow />} element={<AddVideoFlow />}
/> />
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route <Route
path="/new-content/learn-english/:level/courses/add-practice" path="/new-content/learn-english/:level/courses/add-practice"
element={<AddPracticeFlow />} element={<AddPracticeFlow />}

View File

@ -0,0 +1,593 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react"
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"])
const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
export interface DynamicSchemaSlotRow {
id: string
kind: string
label?: string
required?: boolean
}
function slotMediaMode(kind: string): "image" | "audio" | "text" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u.startsWith("AUDIO")) return "audio"
return "text"
}
function isHttpUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim())
}
function fileLabelFromValue(raw: string): string {
const t = raw.trim()
if (!t) return "No audio"
try {
if (isHttpUrl(t)) {
const path = new URL(t).pathname.split("/").filter(Boolean)
const last = path[path.length - 1]
return last ? decodeURIComponent(last) : "Audio clip"
}
} catch {
/* ignore */
}
const parts = t.split("/").filter(Boolean)
const last = parts[parts.length - 1]
return last ? decodeURIComponent(last) : "Audio clip"
}
function DynamicImageSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const valueAtFocusRef = useRef("")
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (!IMAGE_EXT.has(ext)) {
toast.error("Unsupported image format", {
description: "Use JPG, PNG, WEBP, or GIF.",
})
return
}
if (file.size > MAX_IMAGE_BYTES) {
toast.error("Image is too large", { description: "Maximum size is 10 MB." })
return
}
setUploading(true)
try {
const res = await uploadImageFile(file)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Upload did not return a URL")
onChange(url)
toast.success("Image uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload image")
} finally {
setUploading(false)
}
},
[disabled, uploading, onChange],
)
const importUrl = useCallback(async () => {
const trimmed = value.trim()
if (!trimmed || !isHttpUrl(trimmed)) return
if (trimmed === valueAtFocusRef.current) return
setUploading(true)
try {
const res = await uploadImageFile(trimmed)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Import did not return a URL")
onChange(url)
toast.success("Image URL imported to storage")
} catch (e) {
console.error(e)
toast.error("Could not import image from URL")
} finally {
setUploading(false)
}
}, [value, onChange])
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}
const zoneDisabled = disabled || uploading
const hasImage = Boolean(value.trim())
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div
className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]"
onDragOver={
hasImage
? (e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}
: undefined
}
onDragLeave={
hasImage
? (e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}
: undefined
}
onDrop={
hasImage
? (e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}
: undefined
}
>
{hasImage ? (
<div
className={cn(
"relative mx-auto aspect-video max-h-48 w-full max-w-lg overflow-hidden rounded-lg border bg-white shadow-sm transition-colors",
dragActive ? "border-[#9E2891] ring-2 ring-[#9E289133]" : "border-grayScale-200",
)}
>
<ResolvedImage src={value} alt="" className="h-full w-full object-contain" />
<button
type="button"
className="absolute right-1.5 top-1.5 rounded-full bg-white/95 p-1.5 text-[#9E2891] shadow-md hover:bg-white"
onClick={() => onChange("")}
disabled={zoneDisabled}
aria-label="Remove image"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex h-full min-h-[88px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-5 text-center text-xs text-grayScale-500 sm:text-sm lg:min-h-[120px]">
<ImageIcon className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
<p>Preview appears here after you upload or paste a URL on the right.</p>
</div>
)}
</div>
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={handleFileChange}
disabled={zoneDisabled}
/>
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}}
onDragLeave={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<SpinnerIcon className="h-4 w-4" /> Uploading
</p>
) : (
<>
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
JPG, PNG, WebP, GIF (max 10 MB)
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => {
valueAtFocusRef.current = e.currentTarget.value.trim()
}}
onBlur={() => void importUrl()}
placeholder="https://…"
title="Leave the field after pasting a public URL to import to storage"
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
</div>
</div>
)
}
function WaveformDecor({ active }: { active: boolean }) {
const heights = [0.35, 0.55, 0.42, 0.7, 0.5, 0.62, 0.4, 0.58, 0.48, 0.66, 0.44, 0.52, 0.38, 0.6, 0.46, 0.54]
return (
<div className="flex h-7 flex-1 items-end justify-center gap-[2px] overflow-hidden px-0.5">
{heights.map((h, idx) => (
<div
key={idx}
className={cn(
"w-[3px] min-w-[2px] rounded-full transition-colors",
active ? "bg-brand-500/80" : "bg-grayScale-300/90",
)}
style={{ height: `${Math.round(h * 100)}%` }}
/>
))}
</div>
)
}
function DynamicAudioSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement>(null)
const valueAtFocusRef = useRef("")
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [resolvedSrc, setResolvedSrc] = useState("")
const [playing, setPlaying] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
const raw = value.trim()
if (!raw) {
setResolvedSrc("")
return
}
try {
const url = await resolveMediaPreviewUrl(raw)
if (!cancelled) setResolvedSrc(url || raw)
} catch {
if (!cancelled) setResolvedSrc(raw)
}
})()
return () => {
cancelled = true
}
}, [value])
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (!AUDIO_EXT.has(ext)) {
toast.error("Unsupported audio format")
return
}
if (file.size > MAX_AUDIO_BYTES) {
toast.error("Audio file must be 50MB or less")
return
}
setUploading(true)
try {
const res = await uploadAudioFile(file)
const url = res.data?.data?.url?.trim()
const objectKey = res.data?.data?.object_key?.trim()
const stored = url || objectKey
if (!stored) throw new Error("Upload did not return a URL or key")
onChange(stored)
toast.success("Audio uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload audio")
} finally {
setUploading(false)
}
},
[disabled, uploading, onChange],
)
const importUrl = useCallback(async () => {
const trimmed = value.trim()
if (!trimmed || !isHttpUrl(trimmed)) return
if (trimmed === valueAtFocusRef.current) return
setUploading(true)
try {
const res = await uploadAudioFile(trimmed)
const url = res.data?.data?.url?.trim()
const objectKey = res.data?.data?.object_key?.trim()
const stored = url || objectKey
if (!stored) throw new Error("Import did not return a URL or key")
onChange(stored)
toast.success("Audio URL imported to storage")
} catch (e) {
console.error(e)
toast.error("Could not import audio from URL")
} finally {
setUploading(false)
}
}, [value, onChange])
const togglePlay = async () => {
const el = audioRef.current
if (!el || !resolvedSrc) return
if (playing) {
el.pause()
setPlaying(false)
} else {
try {
await el.play()
} catch {
toast.error("Could not play audio")
}
}
}
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}
const zoneDisabled = disabled || uploading
const hasMedia = Boolean(value.trim() && resolvedSrc)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
className="sr-only"
onChange={handleFileChange}
disabled={zoneDisabled}
/>
{hasMedia ? (
<div className="flex items-center gap-2 rounded-lg border border-[#9E289140] bg-[#FAF5FF]/80 px-2 py-2 shadow-sm">
<button
type="button"
onClick={() => void togglePlay()}
disabled={!resolvedSrc || zoneDisabled}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#9E2891] text-white shadow-sm transition hover:bg-[#8a217d] disabled:opacity-40"
aria-label={playing ? "Pause" : "Play"}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</button>
<div className="min-w-0 flex-1">
<WaveformDecor active={playing} />
<p className="mt-0.5 truncate text-[11px] font-medium leading-tight text-[#9E2891]">
{fileLabelFromValue(value)}
</p>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-[#9E2891] transition hover:bg-white/80"
onClick={() => {
audioRef.current?.pause()
setPlaying(false)
onChange("")
}}
disabled={zoneDisabled}
aria-label="Remove audio"
>
<X className="h-4 w-4" />
</button>
<audio
ref={audioRef}
src={resolvedSrc}
className="hidden"
onPlay={() => setPlaying(true)}
onEnded={() => setPlaying(false)}
onPause={() => setPlaying(false)}
/>
</div>
) : (
<div className="flex h-full min-h-[80px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-4 text-center text-xs text-grayScale-500 sm:text-sm">
<Mic className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
<p>Playback preview appears here after you upload or paste a URL on the right.</p>
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
<button
type="button"
disabled={zoneDisabled}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e: DragEvent) => {
e.preventDefault()
if (!zoneDisabled) setDragActive(true)
}}
onDragLeave={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e: DragEvent) => {
e.preventDefault()
setDragActive(false)
if (zoneDisabled) return
const file = e.dataTransfer.files?.[0]
if (file) void processFile(file)
}}
className={cn(
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
dragActive && "border-[#9E2891] bg-[#9E289108]",
zoneDisabled && "cursor-not-allowed opacity-60",
)}
>
{uploading ? (
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<SpinnerIcon className="h-4 w-4" /> Uploading
</p>
) : (
<>
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
<p className="text-sm">
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
<span className="text-grayScale-500">or paste a URL below</span>
</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
MP3, WAV, OGG, M4A, AAC, WebM, FLAC (max 50 MB)
</p>
</>
)}
</button>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => {
valueAtFocusRef.current = e.currentTarget.value.trim()
}}
onBlur={() => void importUrl()}
placeholder="https://…"
title="Leave the field after pasting a public URL to import to storage"
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
autoComplete="off"
/>
</div>
</div>
</div>
)
}
export interface DynamicSchemaSlotFieldProps {
row: DynamicSchemaSlotRow
value: string
onChange: (next: string) => void
disabled?: boolean
}
export function DynamicSchemaSlotField({
row,
value,
onChange,
disabled = false,
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const baseLabel =
row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}`
if (mode === "text") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<Textarea
rows={3}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="URL, plain text, or JSON object"
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled}
/>
</div>
)
}
if (mode === "image") {
return (
<DynamicImageSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}
return (
<DynamicAudioSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}

View File

@ -12,6 +12,7 @@ import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import { import {
getQuestionTypeDefinitionById, getQuestionTypeDefinitionById,
getQuestionTypeDefinitions, getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api" } from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types" import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
import { Input } from "../ui/input" import { Input } from "../ui/input"
@ -22,6 +23,7 @@ import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio" import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage" import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicSchemaSlotField } from "./DynamicSchemaSlotField"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC" export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD" export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
@ -712,9 +714,9 @@ export function PracticeQuestionEditorFields({
return ( return (
<> <>
<div className="mt-5 space-y-5"> <div className="mt-3 space-y-3">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
<textarea <textarea
value={value.questionText} value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })} onChange={(e) => patch({ questionText: e.target.value })}
@ -731,10 +733,10 @@ export function PracticeQuestionEditorFields({
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}> <Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)} className="h-9 text-sm">
<option value="MCQ">Multiple Choice</option> <option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option> <option value="SHORT">Short Answer</option>
@ -742,25 +744,29 @@ export function PracticeQuestionEditorFields({
<option value="DYNAMIC">Dynamic (schema-driven)</option> <option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
<Select <Select
value={value.difficultyLevel} value={value.difficultyLevel}
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })} onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
className="h-9 text-sm"
> >
<option value="EASY">Easy</option> <option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option> <option value="HARD">Hard</option>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
<Input <Input
type="number" type="number"
value={value.points} value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })} onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1} min={1}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)} className={cn(
"h-9 text-sm",
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)} aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/> />
{showFieldErrors && fieldErrors.points ? ( {showFieldErrors && fieldErrors.points ? (
@ -770,12 +776,11 @@ export function PracticeQuestionEditorFields({
</div> </div>
{value.questionType === "DYNAMIC" && ( {value.questionType === "DYNAMIC" && (
<div className="space-y-5 rounded-xl border border-violet-200 bg-violet-50/50 p-4 sm:p-5"> <div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-sm leading-relaxed text-grayScale-600"> <p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
Pick a question type definition, then fill each stimulus/response slot. Element{" "} <span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
<code className="rounded bg-white px-1 text-xs">id</code> and{" "} (imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
<code className="rounded bg-white px-1 text-xs">kind</code> must match the definition schema. Use JSON slots: text or JSON.
for object values (e.g. <code className="text-xs">{"{\"placeholder\":\"Type here\"}"}</code>).
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -785,11 +790,12 @@ export function PracticeQuestionEditorFields({
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""} value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)} onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
disabled={definitionsLoading || definitionDetailLoading} disabled={definitionsLoading || definitionDetailLoading}
className="h-9 text-sm"
> >
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option> <option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
{typeDefinitions.map((d) => ( {typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}> <option key={d.id} value={String(d.id)}>
#{d.id} {d.display_name} ({d.key}) {questionTypeDefinitionListLabel(d)}
</option> </option>
))} ))}
</Select> </Select>
@ -798,52 +804,36 @@ export function PracticeQuestionEditorFields({
<p className="text-sm font-medium text-grayScale-500">Loading schema</p> <p className="text-sm font-medium text-grayScale-500">Loading schema</p>
) : null} ) : null}
{value.dynamicStimulusRows.length > 0 ? ( {value.dynamicStimulusRows.length > 0 ? (
<div className="space-y-3"> <div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Stimulus</p> <p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{value.dynamicStimulusRows.map((row) => ( {value.dynamicStimulusRows.map((row) => (
<div <div
key={`stimulus-${row.id}`} key={`stimulus-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm" className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
> >
<div className="flex flex-wrap items-baseline justify-between gap-2"> <DynamicSchemaSlotField
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span> row={row}
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""} value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`stimulus:${row.id}`, e.target.value)} onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
placeholder="URL, plain text, or JSON object" disabled={controlsDisabled}
className="min-h-[72px] resize-y font-mono text-[13px]"
/> />
</div> </div>
))} ))}
</div> </div>
) : null} ) : null}
{value.dynamicResponseRows.length > 0 ? ( {value.dynamicResponseRows.length > 0 ? (
<div className="space-y-3"> <div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Response</p> <p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
{value.dynamicResponseRows.map((row) => ( {value.dynamicResponseRows.map((row) => (
<div <div
key={`response-${row.id}`} key={`response-${row.id}`}
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm" className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
> >
<div className="flex flex-wrap items-baseline justify-between gap-2"> <DynamicSchemaSlotField
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span> row={row}
<span className="text-[11px] font-mono text-grayScale-500">
{row.id} · {row.kind}
{row.required ? <span className="text-red-500"> *</span> : null}
</span>
</div>
<Textarea
rows={3}
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""} value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(e) => setDynamicField(`response:${row.id}`, e.target.value)} onChange={(next) => setDynamicField(`response:${row.id}`, next)}
placeholder="URL, plain text, or JSON object" disabled={controlsDisabled}
className="min-h-[72px] resize-y font-mono text-[13px]"
/> />
</div> </div>
))} ))}
@ -853,7 +843,7 @@ export function PracticeQuestionEditorFields({
)} )}
{value.questionType === "MCQ" && ( {value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4"> <div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5"> <div className="space-y-2.5">
{value.options.map((option, optIdx) => ( {value.options.map((option, optIdx) => (
@ -966,7 +956,7 @@ export function PracticeQuestionEditorFields({
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6"> <div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input <Input

View File

@ -0,0 +1,175 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
for (const r of def.response_schema) o[`response:${r.id}`] = ""
return o
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
*/
export function legacyQuestionTypeFromDefinition(
def: QuestionTypeDefinition,
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
if (definitionUsesDynamicPayload(def)) return null
const k = def.key.toLowerCase()
if (k === "multiple_choice") return "MCQ"
if (k === "true_false") return "TRUE_FALSE"
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
return null
}
export interface LearnEnglishDefinitionQuestionInput {
questionText: string
questionTypeDefinitionId: number
dynamicFieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]
voicePromptUrl?: string
sampleAnswerVoiceUrl?: string
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
status: "DRAFT" | "PUBLISHED",
): CreateQuestionRequest {
const difficulty = "EASY"
const points = 1
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues: q.dynamicFieldValues ?? {},
})
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: payload,
}
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const options: QuestionOption[] = (q.mcqOptions ?? [])
.filter((o) => o.option_text.trim())
.map((o, idx) => ({
option_order: idx + 1,
option_text: o.option_text.trim(),
is_correct: o.is_correct,
}))
return {
question_text,
question_type: "MCQ",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "TRUE_FALSE") {
const trueCorrect = q.trueFalseAnswerIsTrue !== false
const options: QuestionOption[] = [
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
]
return {
question_text,
question_type: "TRUE_FALSE",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "SHORT_ANSWER") {
const short_answers = (q.shortAnswers ?? [])
.map((s) => s.trim())
.filter(Boolean)
.map((acceptable_answer) => ({
acceptable_answer,
match_type: "CASE_INSENSITIVE" as const,
}))
return {
question_text,
question_type: "SHORT_ANSWER",
difficulty_level: difficulty,
points,
status,
short_answers,
}
}
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: { stimulus: [], response: [] },
}
}
export function validateDefinitionQuestion(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
index1Based: number,
): string | null {
const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) {
for (const row of def.stimulus_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
return null
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
if (opts.length < 2)
return `Question ${n} (${def.display_name}): add at least two choices with text.`
if (!opts.some((o) => o.is_correct))
return `Question ${n} (${def.display_name}): mark one correct choice.`
return null
}
if (legacy === "TRUE_FALSE") return null
if (legacy === "SHORT_ANSWER") {
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
if (answers.length < 1)
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
return null
}
return null
}

View File

@ -0,0 +1,135 @@
import type { AxiosError } from "axios"
import {
addQuestionToSet,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
const ax = err as AxiosError<{ message?: string; error?: string }>
const data = ax.response?.data
if (data && typeof data === "object") {
const m = data.message ?? data.error
if (typeof m === "string" && m.trim()) return m.trim()
}
if (err instanceof Error && err.message) return err.message
return "Request failed"
}
export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const filled = questions.filter((q) => q.questionText.trim())
if (filled.length === 0) return "Add at least one question with prompt text."
const byId = new Map(definitions.map((d) => [d.id, d]))
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
return `Question ${i + 1}: select a question type from the list.`
}
const def = byId.get(q.questionTypeDefinitionId)
if (!def) {
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
}
const err = validateDefinitionQuestion(def, q, i + 1)
if (err) return err
}
return null
}
/**
* Learn English parent-linked practice: create PRACTICE question set,
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
*/
export async function executeLearnEnglishPracticeCreation(opts: {
parentKind: PracticeParentKind
parentId: number
status: "DRAFT" | "PUBLISHED"
questionSetTitle: string
questionSetDescription?: string | null
shuffleQuestions: boolean
practiceTitle: string
storyDescription: string
storyImage: string
quickTips: string
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
}): Promise<{ questionSetId: number; practiceId: number }> {
const err = validateLearnEnglishQuestionsWithDefinitions(
opts.questions,
opts.definitions,
)
if (err) throw new Error(err)
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({
title: opts.questionSetTitle.trim() || "Practice question set",
description: opts.questionSetDescription?.trim() || null,
set_type: "PRACTICE",
owner_type: opts.parentKind,
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
})
const setId = setRes.data?.data?.id
if (!setId) {
throw new Error(
(setRes.data as { message?: string } | undefined)?.message ??
"Could not create question set",
)
}
const toCreate = opts.questions.filter((q) => q.questionText.trim())
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
const qRes = await createQuestion(payload)
const questionId = qRes.data?.data?.id
if (!questionId) {
throw new Error(
(qRes.data as { message?: string } | undefined)?.message ??
"Could not create question",
)
}
await addQuestionToSet(setId, {
question_id: questionId,
display_order: displayOrder,
})
}
const practiceRes = await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
})
const practiceId = practiceRes.data?.data?.id
if (!practiceId) {
throw new Error(
(practiceRes.data as { message?: string } | undefined)?.message ??
"Could not create practice",
)
}
return { questionSetId: setId, practiceId }
}

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Link, Link,
useNavigate, useNavigate,
@ -6,16 +6,27 @@ import {
useSearchParams, useSearchParams,
} from "react-router-dom"; } 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 { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg"; import successIcon from "../../assets/success.svg";
import type { PracticeParentKind } from "../../types/course.types";
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
import {
executeLearnEnglishPracticeCreation,
learnEnglishPracticeApiErrorMessage,
validateLearnEnglishQuestionsWithDefinitions,
} from "../../lib/learnEnglishPracticePublish";
import { ContextStep } from "./components/practice-steps/ContextStep"; import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep"; import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep"; import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep"; import { ReviewStep } from "./components/practice-steps/ReviewStep";
const STEP_LABELS = ["Practice", "Questions", "Review"] as const;
export function AddPracticeFlow() { export function AddPracticeFlow() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level } = useParams<{ level: string }>(); const { level } = useParams<{ level: string }>();
@ -38,6 +49,36 @@ export function AddPracticeFlow() {
const isModuleContext = backTo === "module"; const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules"; const isCourseContext = backTo === "modules";
const parentContext = useMemo((): {
kind: PracticeParentKind;
id: number;
} | null => {
const lid = lessonId ? Number(lessonId) : NaN;
if (Number.isFinite(lid) && lid > 0) return { kind: "LESSON", id: lid };
const mid = moduleId ? Number(moduleId) : NaN;
if (isModuleContext && Number.isFinite(mid) && mid > 0)
return { kind: "MODULE", id: mid };
const cid = courseId ? Number(courseId) : NaN;
if (isCourseContext && Number.isFinite(cid) && cid > 0)
return { kind: "COURSE", id: cid };
return null;
}, [lessonId, moduleId, courseId, isModuleContext, isCourseContext]);
const parentSummary = useMemo(() => {
if (lessonId)
return `Lesson #${lessonId}${lessonTitleDisplay ? `${lessonTitleDisplay}` : ""}`;
if (isModuleContext && moduleId) return `Module #${moduleId}`;
if (isCourseContext && courseId) return `Course #${courseId}`;
return null;
}, [
lessonId,
lessonTitleDisplay,
isModuleContext,
isCourseContext,
moduleId,
courseId,
]);
const backLabel = const backLabel =
backTo === "module" backTo === "module"
? "Back to Module" ? "Back to Module"
@ -51,36 +92,155 @@ export function AddPracticeFlow() {
? `/new-content/learn-english/${level}/courses/${courseId}` ? `/new-content/learn-english/${level}/courses/${courseId}`
: `/new-content/learn-english/${level}/courses`; : `/new-content/learn-english/${level}/courses`;
const flowSteps = isModuleContext
? ["Context", "Persona", "Questions", "Review"]
: ["Context", "Scenario", "Persona", "Questions", "Review"];
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [selectedPersona, setSelectedPersona] = useState<string | null>(
"dawit",
);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
program: "Intermediate",
course: "A2",
title: "", title: "",
description: "", description: "",
selectedVideo: "", storyImageUrl: "",
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.", shuffleQuestions: false,
tips: "",
questions: [ questions: [
{ {
id: "q1", id: "q1",
text: "How long have you been studying English?", questionTypeDefinitionId: null as number | null,
type: "Speaking", text: "",
voicePrompt: "prompt_q1_en.mp3", dynamicFieldValues: {} as Record<string, string>,
sampleAnswer: "prompt_q1_en.mp3", mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
}, },
], ],
}); });
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
[],
);
const [definitionsLoading, setDefinitionsLoading] = useState(true);
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const list = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});
if (!cancelled) setTypeDefinitions(list);
} catch (e) {
if (!cancelled) {
setDefinitionsError(learnEnglishPracticeApiErrorMessage(e));
setTypeDefinitions([]);
}
} finally {
if (!cancelled) setDefinitionsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (typeDefinitions.length === 0) return;
setFormData((fd) => ({
...fd,
questions: fd.questions.map((q) => {
if (q.questionTypeDefinitionId != null) return q;
const def = typeDefinitions[0];
return {
...q,
questionTypeDefinitionId: def.id,
dynamicFieldValues: emptyDynamicFieldValuesForDefinition(def),
};
}),
}));
}, [typeDefinitions]);
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
if (!parentContext) {
toast.error("Missing practice parent", {
description:
"Open this screen from a course, module, or lesson so the API receives parent_kind and parent_id.",
});
return;
}
if (!formData.title.trim() || !formData.description.trim()) {
toast.error("Title and story description are required", {
description: "Complete the first step before publishing.",
});
return;
}
const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: q.trueFalseCorrect !== false,
shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)),
}));
const validationMsg = validateLearnEnglishQuestionsWithDefinitions(
mappedQuestions,
typeDefinitions,
);
if (validationMsg) {
toast.error("Check your questions", { description: validationMsg });
return;
}
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
status,
questionSetTitle: formData.title.trim() || "Practice set",
questionSetDescription: formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions,
practiceTitle: formData.title.trim() || "Untitled practice",
storyDescription: formData.description.trim(),
storyImage: formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(),
questions: mappedQuestions,
definitions: typeDefinitions,
});
toast.success(
status === "PUBLISHED" ? "Practice published" : "Draft saved",
{
description:
"Question set, questions, and parent-linked practice were created.",
},
);
setIsPublished(true);
} catch (e) {
toast.error("Could not save practice", {
description: learnEnglishPracticeApiErrorMessage(e),
});
} finally {
setSubmitting(false);
}
};
const nextStep = () => const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length)); setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) { if (isPublished) {
@ -98,23 +258,46 @@ export function AddPracticeFlow() {
Practice Published Successfully! Practice Published Successfully!
</h1> </h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed"> <p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
Your speaking practice is now active and available inside the module. {lessonId
? "Your speaking practice is saved and linked to this lessons question set."
: "Your speaking practice is saved for the linked course or module."}
</p> </p>
<div className="flex flex-col gap-4 w-full max-w-[400px]"> <div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button <Button
onClick={() => navigate(backPath)} onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white " className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
> >
Go back to Module {backLabel}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
setFormData({ setFormData({
...formData,
title: "", title: "",
description: "", description: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
}); });
}} }}
variant="outline" variant="outline"
@ -127,9 +310,8 @@ export function AddPracticeFlow() {
); );
} }
// Helper to map currentStep to the actual component for the module flow
const renderStep = () => { const renderStep = () => {
if (!isModuleContext) { if (isModuleContext) {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return ( return (
@ -139,102 +321,80 @@ export function AddPracticeFlow() {
nextStep={nextStep} nextStep={nextStep}
navigate={navigate} navigate={navigate}
level={level!} level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/> />
); );
case 2: case 2:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 3:
return (
<ReviewStep
formData={formData}
prevStep={prevStep}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/>
);
default:
return null;
}
}
switch (currentStep) {
case 1:
return ( return (
<ScenarioStep <ScenarioStep
formData={formData} formData={formData}
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
prevStep={prevStep} cancelHref={backPath}
/>
);
case 3:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 4:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 5:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
default:
return null;
}
} else {
// Module Context Flow (Skips Scenario)
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/> />
); );
case 2: case 2:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return ( return (
<QuestionsStep <QuestionsStep
formData={formData} formData={formData}
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
prevStep={prevStep} prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/> />
); );
case 4: case 3:
return ( return (
<ReviewStep <ReviewStep
formData={formData} formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep} prevStep={prevStep}
setIsPublished={setIsPublished} parentSummary={parentSummary}
isModuleContext={isModuleContext} typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/> />
); );
default: default:
return null; return null;
} }
}
}; };
return ( return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen "> <div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<div className="mx-auto max-w-7xl w-full"> <div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<Link <Link
@ -260,33 +420,36 @@ export function AddPracticeFlow() {
</Button> </Button>
</div> </div>
<p className="text-grayScale-400 text-base"> <p className="text-grayScale-400 text-base">
Create a new immersive practice session for students. Create a practice: question types from{" "}
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
</p> </p>
{lessonId ? ( {lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950"> <div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
<p className="font-semibold text-violet-900">Practice for this lesson</p> <p className="font-semibold text-violet-900">Lesson practice</p>
<p className="mt-1 text-violet-800/90"> <p className="mt-1 text-violet-800/90">
This session will be associated with lesson{" "} Linked to lesson{" "}
<span className="font-mono font-bold text-violet-950">#{lessonId}</span> <span className="font-mono font-bold text-violet-950">
#{lessonId}
</span>
{lessonTitleDisplay ? ( {lessonTitleDisplay ? (
<> <>
{" "} {" "}
<span className="font-medium">{lessonTitleDisplay}</span> <span className="font-medium">{lessonTitleDisplay}</span>
</> </>
) : null} ) : null}
. The module-level flow still uses the same steps; use this context when naming and .
configuring the practice.
</p> </p>
</div> </div>
) : null} ) : null}
</div> </div>
<div className="mx-auto w-[70%] mb-12"> <div className="mx-auto w-[70%] mb-12">
<Stepper steps={flowSteps} currentStep={currentStep} /> <Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
</div> </div>
<div <div
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`} className={`mx-auto ${currentStep === 2 ? "max-w-6xl" : "max-w-4xl"}`}
> >
{renderStep()} {renderStep()}
</div> </div>

View File

@ -8,7 +8,10 @@ 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 {
getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types" import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
@ -343,47 +346,46 @@ export function AddQuestionPage() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-4 pb-6">
{/* Page Header */} <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate("/content/questions")} onClick={() => navigate("/content/questions")}
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors" className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600"> <h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
{isEditing ? "Edit Question" : "Add New Question"} {isEditing ? "Edit Question" : "Add New Question"}
</h1> </h1>
<p className="mt-1 text-sm text-grayScale-400"> <p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"} {isEditing ? "Update fields below" : "Create a bank question"}
</p> </p>
</div> </div>
</div> </div>
<div className="max-w-3xl mx-auto"> <div className="mx-auto max-w-2xl">
{loading && ( {loading && (
<Card className="mb-4 border border-grayScale-200"> <Card className="mb-2 border border-grayScale-200">
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent> <CardContent className="py-2.5 text-xs text-grayScale-500">Loading</CardContent>
</Card> </Card>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card className="shadow-sm border border-grayScale-100 rounded-xl"> <Card className="rounded-lg border border-grayScale-100 shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="space-y-0 px-4 py-3 sm:px-5">
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle> <CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-7"> <CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
{/* Question Type */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Question Type Question Type
</label> </label>
<Select <Select
value={formData.type} value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as QuestionType)} onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
className="h-9 text-sm"
> >
<option value="MCQ">Multiple Choice</option> <option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
@ -396,7 +398,7 @@ export function AddQuestionPage() {
{formData.type === "DYNAMIC" && ( {formData.type === "DYNAMIC" && (
<> <>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Question type definition <span className="text-red-500">*</span> Question type definition <span className="text-red-500">*</span>
</label> </label>
<Select <Select
@ -405,49 +407,44 @@ export function AddQuestionPage() {
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value })) setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
} }
required required
className="h-9 text-sm"
> >
<option value="">Select definition</option> <option value="">Select definition</option>
{typeDefinitions.map((d) => ( {typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}> <option key={d.id} value={String(d.id)}>
{d.display_name} ({d.key}) {questionTypeDefinitionListLabel(d)}
</option> </option>
))} ))}
</Select> </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>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
dynamic_payload (JSON) <span className="text-red-500">*</span> dynamic_payload (JSON) <span className="text-red-500">*</span>
</label> </label>
<Textarea <Textarea
value={formData.dynamicPayloadJson} value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={12} rows={7}
className="font-mono text-xs" className="min-h-0 font-mono text-[11px] leading-snug"
spellCheck={false} 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> </div>
</> </>
)} )}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* 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 block text-xs font-medium text-grayScale-600">
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"} {formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
</label> </label>
<Textarea <Textarea
id="question" id="question"
placeholder="Enter your question here..." placeholder="Enter your question here..."
value={formData.question} value={formData.question}
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
rows={3} rows={2}
className="min-h-[72px] text-sm"
required required
/> />
</div> </div>
@ -455,13 +452,11 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */} {/* Options for Multiple Choice */}
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && ( {(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
Options <div className="space-y-1.5">
</label>
<div className="space-y-3">
{formData.options.map((option, index) => ( {formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 group"> <div key={index} className="group flex items-center gap-1.5">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-grayScale-100 text-[10px] font-medium text-grayScale-500">
{index + 1} {index + 1}
</span> </span>
<Input <Input
@ -469,6 +464,7 @@ export function AddQuestionPage() {
onChange={(e) => handleOptionChange(index, e.target.value)} onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`} placeholder={`Option ${index + 1}`}
disabled={formData.type === "TRUE_FALSE"} disabled={formData.type === "TRUE_FALSE"}
className="h-9 text-sm"
required required
/> />
{formData.type === "MCQ" && formData.options.length > 2 && ( {formData.type === "MCQ" && formData.options.length > 2 && (
@ -477,17 +473,22 @@ export function AddQuestionPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => removeOption(index)} onClick={() => removeOption(index)}
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all" className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
</div> </div>
))} ))}
{formData.type === "MCQ" && ( {formData.type === "MCQ" && (
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30"> <Button
<Plus className="h-4 w-4" /> type="button"
Add Option variant="outline"
onClick={addOption}
className="mt-0.5 h-9 w-full border-dashed border-grayScale-200 text-xs text-grayScale-500 hover:border-brand-500/30 hover:text-brand-500"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add option
</Button> </Button>
)} )}
</div> </div>
@ -499,8 +500,8 @@ export function AddQuestionPage() {
{/* Correct Answer */} {/* Correct Answer */}
{formData.type !== "DYNAMIC" && ( {formData.type !== "DYNAMIC" && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"} {formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
</label> </label>
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? ( {formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
<Select <Select
@ -508,6 +509,7 @@ export function AddQuestionPage() {
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value })) setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
} }
className="h-9 text-sm"
required required
> >
<option value="">Select correct answer</option> <option value="">Select correct answer</option>
@ -519,7 +521,7 @@ export function AddQuestionPage() {
</Select> </Select>
) : ( ) : (
<Textarea <Textarea
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."} placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer} value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
onChange={(e) => onChange={(e) =>
setFormData((prev) => setFormData((prev) =>
@ -529,6 +531,7 @@ export function AddQuestionPage() {
) )
} }
rows={2} rows={2}
className="min-h-[60px] text-sm"
required required
/> />
)} )}
@ -538,16 +541,16 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Points and Difficulty side by side */} {/* Points and Difficulty side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
{/* Points */}
<div> <div>
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
Points Points
</label> </label>
<Input <Input
id="points" id="points"
type="number" type="number"
min="1" min="1"
className="h-9 text-sm"
value={formData.points} value={formData.points}
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 })) setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
@ -556,14 +559,12 @@ export function AddQuestionPage() {
/> />
</div> </div>
{/* Difficulty */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
Difficulty (Optional)
</label>
<Select <Select
value={formData.difficulty} value={formData.difficulty}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))} onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
className="h-9 text-sm"
> >
<option value="EASY">Easy</option> <option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
@ -574,12 +575,11 @@ export function AddQuestionPage() {
{/* Status */} {/* Status */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
Status
</label>
<Select <Select
value={formData.status} value={formData.status}
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))} onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
className="h-9 text-sm"
> >
<option value="DRAFT">Draft</option> <option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option> <option value="PUBLISHED">Published</option>
@ -588,58 +588,71 @@ export function AddQuestionPage() {
</div> </div>
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && ( {(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
<> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"} Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
</label> </label>
<Textarea <Textarea
value={formData.voicePrompt} value={formData.voicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
rows={2} rows={2}
placeholder="Please say your answer..." placeholder="URL or key…"
className="min-h-[60px] text-sm"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"} Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
</label> </label>
<Textarea <Textarea
value={formData.sampleAnswerVoicePrompt} value={formData.sampleAnswerVoicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
rows={2} rows={2}
placeholder="Sample spoken answer..." placeholder="URL or key…"
className="min-h-[60px] text-sm"
/> />
</div> </div>
</> </div>
)} )}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label> <label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
<Input <Input
value={formData.tips} value={formData.tips}
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for learners" placeholder="Short tip"
className="h-9 text-sm"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label> <label className="mb-1 block text-xs font-medium text-grayScale-600">Explanation (opt.)</label>
<Textarea <Textarea
value={formData.explanation} value={formData.explanation}
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
rows={2} rows={2}
placeholder="Explain why the answer is correct" placeholder="Why this answer"
className="min-h-[60px] text-sm"
/> />
</div> </div>
</div>
{/* Actions */} <div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100"> <Button
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50"> type="button"
variant="outline"
onClick={() => navigate("/content/questions")}
className="h-9 w-full text-sm sm:w-auto"
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all"> <Button
{isEditing ? "Update Question" : "Create Question"} type="submit"
disabled={submitting || loading}
className="h-9 w-full bg-brand-500 text-sm text-white hover:bg-brand-600 sm:w-auto"
>
{isEditing ? "Update" : "Create"}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -0,0 +1,379 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
ArrowLeft,
BookOpen,
Calendar,
Clock,
Hash,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { getPracticesByParentLesson } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
} from "../../types/course.types";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
function unwrapPracticesEnvelope(
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
): GetPracticesByParentContextResponse["data"] | null {
const b = res.data;
if (!b) return null;
return b.data ?? b.Data ?? null;
}
function formatPracticeDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
function PracticeCard({
practice,
index,
total,
}: {
practice: ParentContextPractice;
index: number;
total: number;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
const showThumb = Boolean(thumb) && !imgFailed;
return (
<Card
className={cn(
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
)}
>
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row lg:items-stretch">
<div className="relative shrink-0 lg:w-[280px]">
<div
className={cn(
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
)}
>
{showThumb ? (
<>
<img
src={thumb!}
alt=""
className="h-full w-full object-cover"
onError={() => setImgFailed(true)}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
</>
) : (
<div className="flex flex-col items-center gap-2 text-grayScale-400">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
<BookOpen className="h-7 w-7" />
</div>
<span className="text-[11px] font-semibold uppercase tracking-wider">
No cover image
</span>
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
</div>
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
{practice.title}
</h2>
{practice.story_description?.trim() ? (
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
Story & instructions
</p>
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
{practice.story_description}
</p>
</div>
) : null}
{practice.quick_tips?.trim() ? (
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
Quick tips
</p>
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
{practice.quick_tips}
</p>
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Hash className="h-3 w-3 opacity-70" aria-hidden />
Question set {practice.question_set_id}
</Badge>
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Clock className="h-3 w-3 opacity-70" aria-hidden />
{formatPracticeDate(practice.created_at)}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
export function LessonPracticesPage() {
const navigate = useNavigate();
const { level, courseId, moduleId, lessonId } = useParams<{
level: string;
courseId: string;
moduleId: string;
lessonId: string;
}>();
const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
const load = useCallback(async () => {
if (!validLesson) {
setLoading(false);
setLoadError("Invalid lesson.");
setPractices([]);
return;
}
setLoading(true);
setLoadError(null);
try {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
} catch {
setPractices([]);
setTotalCount(0);
setLoadError("Could not load practices for this lesson.");
toast.error("Failed to load practices");
} finally {
setLoading(false);
}
}, [lid, validLesson]);
useEffect(() => {
void load();
}, [load]);
const displayTitle =
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
</div>
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
<Link
to={backHref}
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
<ArrowLeft className="h-4 w-4" />
</span>
Back to module
</Link>
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
Lesson practices
</p>
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
{displayTitle}
</h1>
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
Review speaking practices linked to this lesson. Thumbnails
and copy come from your published practice content.
</p>
{!loading && !loadError ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
{practices.length}{" "}
{practices.length === 1 ? "practice" : "practices"}
</Badge>
{totalCount > practices.length ? (
<span className="text-[12px] text-grayScale-500">
Showing {practices.length} of {totalCount}
</span>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Add practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
<div className="text-center">
<p className="text-[16px] font-semibold text-grayScale-800">
Loading practices
</p>
<p className="mt-1 text-[14px] text-grayScale-500">
Fetching content for this lesson
</p>
</div>
</CardContent>
</Card>
) : loadError ? (
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle className="h-8 w-8" />
</div>
<div>
<p className="text-lg font-semibold text-grayScale-900">
Something went wrong
</p>
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
{loadError}
</p>
</div>
<Button
type="button"
variant="outline"
className="rounded-xl border-grayScale-300 font-semibold"
onClick={() => void load()}
>
Try again
</Button>
</CardContent>
</Card>
) : practices.length === 0 ? (
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
</div>
<p className="text-xl font-semibold text-grayScale-900">
No practices yet
</p>
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
This lesson does not have any linked practices. Create one to
give learners a structured speaking activity after the video.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Create practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
asChild
>
<Link to={backHref}>Return to module</Link>
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{practices.map((p, i) => (
<PracticeCard
key={p.id}
practice={p}
index={i}
total={practices.length}
/>
))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -404,6 +404,11 @@ export function ModuleDetailPage() {
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`, `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`,
) )
} }
onViewPractices={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
)
}
/> />
))} ))}
</div> </div>

View File

@ -1,5 +1,13 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { MoreVertical, Edit2, Play, Pencil, Trash2, Calendar } from "lucide-react"; import {
BookOpen,
Calendar,
Edit2,
MoreVertical,
Pencil,
Play,
Trash2,
} from "lucide-react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Dialog, Dialog,
@ -32,14 +40,16 @@ interface VideoCardProps {
*/ */
videoUrl?: string; videoUrl?: string;
/** /**
* When true, shows edit/delete in the top-right of the thumbnail (same * When true, shows edit/delete (and optional view practices) in the top-right
* hover pattern as module cards) and removes the footer + overflow menu. * of the thumbnail on hover, and removes the footer + overflow menu.
*/ */
hoverModuleActions?: boolean; hoverModuleActions?: boolean;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
/** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */ /** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */
onAddPractice?: () => void; onAddPractice?: () => void;
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
onViewPractices?: () => void;
onPublish?: () => void; onPublish?: () => void;
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */ /** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null; description?: string | null;
@ -56,6 +66,7 @@ export function VideoCard({
onDelete, onDelete,
onPublish, onPublish,
onAddPractice, onAddPractice,
onViewPractices,
hoverModuleActions = false, hoverModuleActions = false,
description, description,
}: VideoCardProps) { }: VideoCardProps) {
@ -128,10 +139,25 @@ export function VideoCard({
!useGradient && "bg-grayScale-100", !useGradient && "bg-grayScale-100",
)} )}
> >
{hoverModuleActions && (onEdit || onDelete) ? ( {hoverModuleActions && (onEdit || onDelete || onViewPractices) ? (
<div <div
className="absolute right-2 top-2 z-20 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" className="absolute right-2 top-2 z-20 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"
> >
{onViewPractices ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-brand-600 shadow-sm transition-colors hover:bg-brand-50"
aria-label={`View practices for ${title}`}
onClick={(e) => {
e.stopPropagation();
onViewPractices();
}}
>
<BookOpen className="h-3.5 w-3.5" />
</Button>
) : null}
{onEdit ? ( {onEdit ? (
<Button <Button
type="button" type="button"

View File

@ -1,7 +1,11 @@
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react"; import { useRef, useState, type ChangeEvent } from "react";
import { ArrowRight, Loader2, Upload } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card"; import { Card } from "../../../../components/ui/card";
import { Select } from "../../../../components/ui/select"; import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api";
interface ContextStepProps { interface ContextStepProps {
formData: any; formData: any;
@ -9,27 +13,51 @@ interface ContextStepProps {
nextStep: () => void; nextStep: () => void;
navigate: (path: string) => void; navigate: (path: string) => void;
level: string; level: string;
isModuleContext?: boolean;
isCourseContext?: boolean;
} }
/**
* Module / lesson entry: fields that map to POST /practices and POST /question-sets.
*/
export function ContextStep({ export function ContextStep({
formData, formData,
setFormData, setFormData,
nextStep, nextStep,
navigate, navigate,
level, level,
isModuleContext,
isCourseContext,
}: ContextStepProps) { }: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false);
const handleStoryImageFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setUploadingStory(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Missing image URL from upload");
setFormData({ ...formData, storyImageUrl: url });
toast.success("Story image uploaded");
} catch {
toast.error("Could not upload story image");
} finally {
setUploadingStory(false);
}
};
const canContinue =
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return ( return (
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500"> <Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4"> <div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
<h2 className="text-xl font-bold text-grayScale-900 leading-none"> <h2 className="text-xl font-bold text-grayScale-900 leading-none">
Step 1: Context Definition Practice details
</h2> </h2>
<p className="text-grayScale-600 text-base mt-3"> <p className="text-grayScale-600 text-base mt-3">
Define the educational level and curriculum module for this practice. Title, story, optional image, shuffle, and quick tips match the create
practice and question set APIs.
</p> </p>
</div> </div>
@ -40,110 +68,111 @@ export function ContextStep({
<div className="relative flex justify-center"> <div className="relative flex justify-center">
<div <div
className="h-[0.5px] w-full opacity-20 rounded-full" className="h-[0.5px] w-full opacity-20 rounded-full"
style={{ style={{ background: "gray" }}
background: "gray",
}}
/> />
</div> </div>
</div> </div>
<div className="space-y-10 p-10"> <div className="space-y-8 p-10">
{/* Program Field */} <div className="space-y-2">
<div className="space-y-3"> <label className="text-sm font-medium text-grayScale-700">
<label className="text-[16px] text-grayScale-700 ml-1"> Practice title <span className="text-red-500">*</span>
Program{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label> </label>
<div className="relative"> <Input
<div className="absolute left-6 top-1/2 -translate-y-1/2"> value={formData.title ?? ""}
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.program || "Intermediate"}</option>
</Select>
</div>
</div>
{/* Course Field */}
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Course{" "}
<span className="text-grayScale-300 font-medium">
(Auto-selected)
</span>
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<GraduationCap className="h-6 w-6 text-grayScale-600" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
disabled
>
<option>{formData.course || "B2"}</option>
</Select>
</div>
</div>
{/* Select Module Field */}
{(isModuleContext || isCourseContext) && (
<div className="space-y-3">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Module
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
</div>
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
<option value="">Choose a module...</option>
<option value="m1">Introduction Basics</option>
<option value="m2">Daily Routines</option>
<option value="m3">Travel Essentials</option>
</Select>
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
</div>
)}
{/* Select Video Field (Conditional) */}
{isModuleContext && (
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
Select Video
</label>
<div className="relative">
<div className="absolute left-6 top-1/2 -translate-y-1/2">
<Monitor className="h-6 w-6 text-grayScale-400" />
</div>
<Select
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
value={formData.selectedVideo}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, selectedVideo: e.target.value }) setFormData({ ...formData, title: e.target.value })
} }
placeholder="e.g. Lesson 12 conversation drill"
className="h-11 rounded-xl border-grayScale-200"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Story description <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.description ?? ""}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Short scenario for learners…"
className="min-h-[120px] rounded-xl border-grayScale-200"
maxLength={2000}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Quick tips <span className="text-grayScale-400">(optional)</span>
</label>
<Textarea
value={formData.tips ?? ""}
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips on POST /practices)"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Story image <span className="text-grayScale-400">(optional)</span>
</label>
<Input
value={formData.storyImageUrl ?? ""}
onChange={(e) =>
setFormData({ ...formData, storyImageUrl: e.target.value })
}
placeholder="https://… or upload"
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
/>
<input
ref={storyFileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleStoryImageFile}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingStory}
onClick={() => storyFileRef.current?.click()}
className="gap-2"
> >
<option value="">Choose a video</option> {uploadingStory ? (
<option value="v1">Intro to Greetings</option> <Loader2 className="h-4 w-4 animate-spin" />
<option value="v2">Advanced Grammar</option> ) : (
</Select> <Upload className="h-4 w-4" />
</div>
<p className="text-[13px] text-grayScale-400 font-medium px-2">
Select the specific learning module this practice will reinforce.
</p>
</div>
)} )}
Upload image
</Button>
</div>
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
checked={Boolean(formData.shuffleQuestions)}
onChange={(e) =>
setFormData({
...formData,
shuffleQuestions: e.target.checked,
})
}
/>
<span>Shuffle questions in the set</span>
</label>
</div> </div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12"> <div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
<button <button
type="button"
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700" className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={() => onClick={() =>
navigate(`/new-content/learn-english/${level}/courses`) navigate(`/new-content/learn-english/${level}/courses`)
@ -152,11 +181,12 @@ export function ContextStep({
Cancel Cancel
</button> </button>
<Button <Button
type="button"
onClick={nextStep} onClick={nextStep}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2" disabled={!canContinue}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
> >
Next: {isModuleContext ? "Persona" : "Scenario"}{" "} Next: Questions <ArrowRight className="h-5 w-5" />
<ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>
</Card> </Card>

View File

@ -1,14 +1,45 @@
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react"; import { Trash2, Plus, ArrowRight } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card"; import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input"; import { Input } from "../../../../components/ui/input";
import { VoicePrompt } from "./VoicePrompt"; import { DynamicSchemaSlotField } from "../../../../components/content-management/DynamicSchemaSlotField";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import { questionTypeDefinitionListLabel } from "../../../../api/questionTypeDefinitions.api";
import {
definitionUsesDynamicPayload,
emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
function defaultMcqOptions() {
return [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
];
}
function createEmptyQuestionRow(id: string) {
return {
id,
questionTypeDefinitionId: null as number | null,
text: "",
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: defaultMcqOptions(),
trueFalseCorrect: true,
shortAnswers: [""],
};
}
interface QuestionsStepProps { interface QuestionsStepProps {
formData: any; formData: any;
setFormData: (data: any) => void; setFormData: (data: any) => void;
nextStep: () => void; nextStep: () => void;
prevStep: () => void; prevStep: () => void;
typeDefinitions: QuestionTypeDefinition[];
definitionsLoading: boolean;
definitionsError: string | null;
} }
export function QuestionsStep({ export function QuestionsStep({
@ -16,64 +47,365 @@ export function QuestionsStep({
setFormData, setFormData,
nextStep, nextStep,
prevStep, prevStep,
typeDefinitions,
definitionsLoading,
definitionsError,
}: QuestionsStepProps) { }: QuestionsStepProps) {
const addQuestion = () => { const applyDefinitionToQuestion = (
const newQuestion = { index: number,
id: `q${formData.questions.length + 1}`, definitionId: number,
text: "", defs: QuestionTypeDefinition[],
type: "Speaking", ) => {
voicePrompt: "upload_audio.mp3", const def = defs.find((d) => d.id === definitionId);
sampleAnswer: "upload_audio.mp3", const newQuestions = [...formData.questions];
const row = { ...newQuestions[index], questionTypeDefinitionId: definitionId };
if (def) {
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(def);
}
newQuestions[index] = row;
setFormData({ ...formData, questions: newQuestions });
}; };
const setDynamicValue = (qIndex: number, key: string, value: string) => {
const newQuestions = [...formData.questions];
newQuestions[qIndex] = {
...newQuestions[qIndex],
dynamicFieldValues: {
...(newQuestions[qIndex].dynamicFieldValues ?? {}),
[key]: value,
},
};
setFormData({ ...formData, questions: newQuestions });
};
const addQuestion = () => {
const id = `q${Date.now()}`;
const row = createEmptyQuestionRow(id);
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({ setFormData({
...formData, ...formData,
questions: [...formData.questions, newQuestion], questions: [...formData.questions, row],
}); });
}; };
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
if (definitionUsesDynamicPayload(def)) {
return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
). Others: URL, text, or JSON.
</p>
{def.stimulus_schema.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{def.stimulus_schema.map((row) => (
<div
key={`stimulus-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
>
<DynamicSchemaSlotField
row={row}
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `stimulus:${row.id}`, next)
}
/>
</div>
))}
</div>
) : null}
{def.response_schema.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
{def.response_schema.map((row) => (
<div
key={`response-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
>
<DynamicSchemaSlotField
row={row}
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
onChange={(next) =>
setDynamicValue(i, `response:${row.id}`, next)
}
/>
</div>
))}
</div>
) : null}
</div>
);
}
const legacy = legacyQuestionTypeFromDefinition(def);
if (legacy === "MCQ") {
return (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Choices (mark one correct)
</label>
<div className="space-y-2">
{(q.mcqOptions ?? defaultMcqOptions()).map(
(opt: { text: string; isCorrect: boolean }, j: number) => (
<div
key={j}
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
>
<Input
value={opt.text}
onChange={(e) => {
const newQuestions = [...formData.questions];
const opts = [
...(newQuestions[i].mcqOptions ?? defaultMcqOptions()),
];
opts[j] = { ...opts[j], text: e.target.value };
newQuestions[i].mcqOptions = opts;
setFormData({ ...formData, questions: newQuestions });
}}
className="min-w-0 flex-1 rounded-lg border-grayScale-200"
placeholder={`Option ${j + 1}`}
/>
<label className="flex shrink-0 items-center gap-2 text-sm text-grayScale-600">
<input
type="radio"
name={`mcq-correct-${q.id}`}
checked={opt.isCorrect}
onChange={() => {
const newQuestions = [...formData.questions];
const opts = (
newQuestions[i].mcqOptions ?? defaultMcqOptions()
).map((o: { text: string; isCorrect: boolean }, k: number) => ({
...o,
isCorrect: k === j,
}));
newQuestions[i].mcqOptions = opts;
setFormData({ ...formData, questions: newQuestions });
}}
/>
Correct
</label>
</div>
),
)}
</div>
</div>
);
}
if (legacy === "TRUE_FALSE") {
return (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Correct answer
</span>
<div className="flex flex-wrap gap-4">
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
<input
type="radio"
name={`tf-${q.id}`}
checked={q.trueFalseCorrect !== false}
onChange={() => {
const newQuestions = [...formData.questions];
newQuestions[i].trueFalseCorrect = true;
setFormData({ ...formData, questions: newQuestions });
}}
/>
True
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
<input
type="radio"
name={`tf-${q.id}`}
checked={q.trueFalseCorrect === false}
onChange={() => {
const newQuestions = [...formData.questions];
newQuestions[i].trueFalseCorrect = false;
setFormData({ ...formData, questions: newQuestions });
}}
/>
False
</label>
</div>
</div>
);
}
if (legacy === "SHORT_ANSWER") {
return (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Acceptable answers
</label>
{(q.shortAnswers ?? [""]).map((line: string, j: number) => (
<div key={j} className="flex gap-2">
<Input
value={line}
onChange={(e) => {
const newQuestions = [...formData.questions];
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
lines[j] = e.target.value;
newQuestions[i].shortAnswers = lines;
setFormData({ ...formData, questions: newQuestions });
}}
className="rounded-lg border-grayScale-200"
placeholder="Acceptable wording"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newQuestions = [...formData.questions];
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
lines.splice(j, 1);
newQuestions[i].shortAnswers =
lines.length > 0 ? lines : [""];
setFormData({ ...formData, questions: newQuestions });
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newQuestions = [...formData.questions];
newQuestions[i].shortAnswers = [
...(newQuestions[i].shortAnswers ?? [""]),
"",
];
setFormData({ ...formData, questions: newQuestions });
}}
>
Add acceptable answer
</Button>
</div>
);
}
return (
<p className="rounded-lg border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900">
This definition has no schema rows and is not mapped to MCQ / TrueFalse /
Short answer. It will be submitted as{" "}
<span className="font-mono">DYNAMIC</span> with an empty payload.
</p>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1 px-2"> <div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700"> <h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
Create Practice Questions
</h2>
<p className="text-grayScale-400 text-lg"> <p className="text-grayScale-400 text-lg">
Define the dialogue flow and interactions for this scenario. Question types are loaded from{" "}
<code className="rounded bg-grayScale-100 px-1 text-sm">
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
</p> </p>
</div> </div>
{definitionsError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{definitionsError}
</div>
) : null}
{definitionsLoading ? (
<p className="px-2 text-sm text-grayScale-500">Loading question types</p>
) : null}
<div className="space-y-6"> <div className="space-y-6">
{formData.questions.map((q: any, i: number) => ( {formData.questions.map((q: any, i: number) => {
const def = typeDefinitions.find(
(d) => d.id === q.questionTypeDefinitionId,
);
return (
<Card <Card
key={q.id} key={q.id}
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative" className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
> >
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" /> <div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6"> <div className="space-y-6 px-5 pb-7 pt-4 pl-7">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
<div className="flex items-center gap-3"> <span className="text-base font-bold text-grayScale-500">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
Question {i + 1} Question {i + 1}
</span> </span>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
type="button"
className="text-brand-500 hover:bg-brand-50 rounded-lg" className="text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => { onClick={() => {
const newQuestions = formData.questions.filter( const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id, (item: any) => item.id !== q.id,
); );
if (newQuestions.length > 0) {
setFormData({ ...formData, questions: newQuestions }); setFormData({ ...formData, questions: newQuestions });
return;
}
const row = createEmptyQuestionRow("q1");
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues =
emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({ ...formData, questions: [row] });
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3"> <div className="space-y-2">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest"> <label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
QUESTION PROMPT Question type
</label>
<select
className="h-11 w-full max-w-xl rounded-lg border border-grayScale-200 bg-white px-3 text-sm font-medium text-grayScale-800"
disabled={definitionsLoading || typeDefinitions.length === 0}
value={
q.questionTypeDefinitionId != null
? String(q.questionTypeDefinitionId)
: ""
}
onChange={(e) => {
const v = e.target.value;
if (!v) return;
applyDefinitionToQuestion(i, Number(v), typeDefinitions);
}}
>
<option value="">
{definitionsLoading
? "Loading…"
: "Select question type…"}
</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</select>
{def?.description ? (
<p className="text-xs text-grayScale-500">{def.description}</p>
) : null}
</div>
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question text
</label> </label>
<Input <Input
value={q.text} value={q.text}
@ -82,62 +414,35 @@ export function QuestionsStep({
newQuestions[i].text = e.target.value; newQuestions[i].text = e.target.value;
setFormData({ ...formData, questions: newQuestions }); setFormData({ ...formData, questions: newQuestions });
}} }}
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700" className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
placeholder="e.g. How long have you been studying English?" placeholder="Question prompt for learners"
/>
</div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
VOICE PROMPT
</label>
<VoicePrompt
src={q.voicePrompt}
filename={q.voicePrompt}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].voicePrompt = "";
setFormData({ ...formData, questions: newQuestions });
}}
/>
</div>
</div>
<div className="md:w-1/3 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT
</label>
<VoicePrompt
src={q.sampleAnswer}
filename={q.sampleAnswer}
onRemove={() => {
const newQuestions = [...formData.questions];
newQuestions[i].sampleAnswer = "";
setFormData({ ...formData, questions: newQuestions });
}}
/> />
</div> </div>
{def ? renderTypeSpecificFields(q, i, def) : null}
</div> </div>
</Card> </Card>
))} );
})}
<div className="flex items-center gap-8 pt-4"> <div className="flex items-center gap-8 pt-4">
<button <button
type="button"
onClick={addQuestion} onClick={addQuestion}
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all" disabled={definitionsLoading || typeDefinitions.length === 0}
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
> >
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500"> <div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" /> <Plus className="h-3 w-3 stroke-[4]" />
</div>{" "} </div>
Add New Question Add question
</button>
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>{" "}
Add Tips
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center justify-between pt-8"> <div className="flex items-center justify-between pt-8">
<Button <Button
type="button"
onClick={prevStep} onClick={prevStep}
variant="outline" variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm" className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
@ -145,8 +450,10 @@ export function QuestionsStep({
Back Back
</Button> </Button>
<Button <Button
type="button"
onClick={nextStep} onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold " disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
> >
Next: Review <ArrowRight className="ml-2 h-4 w-4" /> Next: Review <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>

View File

@ -1,56 +1,58 @@
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react"; import { Rocket, Info, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card"; import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input"; import { Input } from "../../../../components/ui/input";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import { import {
Avatar, definitionUsesDynamicPayload,
AvatarFallback, legacyQuestionTypeFromDefinition,
AvatarImage, } from "../../../../lib/learnEnglishDefinitionQuestion";
} from "../../../../components/ui/avatar";
import { PERSONAS } from "./constants";
import { VoicePrompt } from "./VoicePrompt";
interface ReviewStepProps { interface ReviewStepProps {
formData: any; formData: any;
selectedPersona: string | null;
prevStep: () => void; prevStep: () => void;
setIsPublished: (val: boolean) => void; parentSummary: string | null;
isModuleContext?: boolean; typeDefinitions: QuestionTypeDefinition[];
canPublish: boolean;
submitting: boolean;
onSaveDraft: () => void;
onPublish: () => void;
} }
export function ReviewStep({ export function ReviewStep({
formData, formData,
selectedPersona,
prevStep, prevStep,
setIsPublished, parentSummary,
isModuleContext, typeDefinitions,
canPublish,
submitting,
onSaveDraft,
onPublish,
}: ReviewStepProps) { }: ReviewStepProps) {
const persona = PERSONAS.find((p) => p.id === selectedPersona);
return ( return (
<div className="space-y-10 animate-in fade-in duration-700"> <div className="space-y-10 animate-in fade-in duration-700">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight"> <h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
Review Practice Questions Review
</h2> </h2>
</div> </div>
{/* 1. Basic Info Card (Image 1436.1) */} {!canPublish && (
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white "> <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white"> <p className="font-semibold">Missing parent for the API</p>
<h3 className="text-[17px] font-extrabold text-grayScale-900"> <p className="mt-1 text-amber-900/90">
Basic Information Open Add Practice from a course, module, or lesson so parent IDs are
</h3> in the URL.
<Button </p>
variant="ghost" </div>
size="sm" )}
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
> <Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white">
<Edit2 className="h-4 w-4" /> <div className="border-b border-grayScale-50 px-5 py-4">
Edit <h3 className="text-[17px] font-extrabold text-grayScale-900">
</Button> Practice
</h3>
</div> </div>
{/* Gradient Divider */}
<div className="relative"> <div className="relative">
<div <div
className="absolute inset-0 flex items-center" className="absolute inset-0 flex items-center"
@ -58,186 +60,99 @@ export function ReviewStep({
> >
<div className="w-full border-t border-grayScale-100" /> <div className="w-full border-t border-grayScale-100" />
</div> </div>
<div className="relative flex justify-center">
<div
className="h-[0.5px] w-full opacity-20 rounded-full"
style={{
background: "gray",
}}
/>
</div> </div>
</div> <div className="p-6 sm:p-8">
<div className="p-8 px-5 flex items-center justify-between "> <div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<div className="flex items-center gap-6"> <div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner">
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
<img <img
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200" src={
alt="Banner" formData.storyImageUrl?.trim() ||
className="w-full h-full object-cover opacity-80" "https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
}
alt="Story"
className="h-full w-full object-cover opacity-80"
/> />
</div> </div>
<div className="space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight"> <h4 className="text-xl font-bold leading-tight text-grayScale-900">
{formData.title || "Business English 101: Communication"} {formData.title || "Untitled"}
</h4> </h4>
<div className="flex items-center gap-6 text-[14px]"> <p className="text-sm text-grayScale-600">
<span className="text-grayScale-900 "> {parentSummary ? (
Program:{" "} <span>
<span className="text-brand-500 ">{formData.program}</span> <span className="font-medium text-grayScale-800">Link:</span>{" "}
</span> {parentSummary}
<span className="text-grayScale-900 ">
Course:{" "}
<span className="text-brand-500 ">{formData.course}</span>
</span>
<span className="text-grayScale-900 font-bold">
Module:{" "}
<span className="text-brand-500 font-extrabold">
Module 101
</span>
</span>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
Persona
</span>
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
<AvatarImage src={persona?.avatar} />
<AvatarFallback>P</AvatarFallback>
</Avatar>
<span className="text-[14px] text-brand-500 capitalize">
{persona?.name || "Alex Johnson"}
</span> </span>
) : (
"—"
)}
</p>
{formData.shuffleQuestions ? (
<p className="text-xs text-grayScale-500">Shuffle questions: on</p>
) : null}
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
{/* 2. Tips Section (Image 1436.1) */}
<div className="space-y-4 px-2"> <div className="space-y-4 px-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none"> <label className="text-[12px] font-bold uppercase tracking-widest text-grayScale-900">
TIPS / GUIDANCE Quick tips
</label> </label>
<Info className="h-4 w-4 text-brand-500" /> <Info className="h-4 w-4 text-brand-500" />
</div> </div>
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl"> <div className="rounded-xl border border-[#E2E8F0] bg-white px-5 py-4 shadow-sm">
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed"> <p className="text-[14px] font-medium leading-relaxed text-grayScale-600">
{formData.tips || {formData.tips?.trim() || "—"}
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
</p> </p>
</div> </div>
</div> </div>
{isModuleContext ? ( <div className="space-y-4">
/* 3. Split Questions & Answers Layout (Image 1413.1) */ <h3 className="px-2 text-lg font-bold text-grayScale-900">Questions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]"> <div className="space-y-4">
{/* Left Column: Questions */}
<div className="border-r border-grayScale-200 flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
Questions
</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => ( {formData.questions.map((q: any, i: number) => (
<div key={q.id} className="relative pl-12"> <QuestionReviewBlock
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70"> key={q.id}
{(i + 1).toString().padStart(2, "0")} q={q}
</span> index={i}
<div className="space-y-8"> typeDefinitions={typeDefinitions}
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
TEXT PROMPT
</span>
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
{q.text}
</p>
</div>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.voicePrompt}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
/> />
</div>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
{/* Right Column: Answers */}
<div className="flex flex-col">
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
<div className="flex items-center gap-3">
<h3 className="text-[16px] font-extrabold ">Answers</h3>
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
{formData.questions.length}
</span>
</div>
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
<Edit2 className="h-3 w-3" />
Edit
</button>
</div>
<div className="p-4 space-y-14">
{formData.questions.map((q: any, i: number) => (
<div key={q.id + "_ans"} className="relative pl-12">
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
{(i + 1).toString().padStart(2, "0")}
</span>
<div className="space-y-4">
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
VOICE PROMPT
</span>
<VoicePrompt
filename={q.sampleAnswer}
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
/>
</div>
</div>
))}
</div>
</div>
</div>
) : (
/* Original Non-Module View */
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => (
<ReviewItem key={q.id} q={q} index={i} />
))}
</div>
)}
{/* Action Footer */}
<div className="flex items-center justify-between pt-12"> <div className="flex items-center justify-between pt-12">
<Button <Button
onClick={prevStep} onClick={prevStep}
variant="outline" variant="outline"
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm" className="h-10 rounded-[6px] border-grayScale-200 bg-white px-10 text-sm font-bold text-grayScale-600 shadow-sm transition-all hover:bg-grayScale-50"
> >
Back Back
</Button> </Button>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
variant="outline" variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm" disabled={submitting || !canPublish}
onClick={onSaveDraft}
className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
> >
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save as Draft Save as Draft
</Button> </Button>
<Button <Button
onClick={() => setIsPublished(true)} disabled={submitting || !canPublish}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm" onClick={onPublish}
className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
> >
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Rocket className="h-4 w-4" /> <Rocket className="h-4 w-4" />
)}
Publish Now Publish Now
</Button> </Button>
</div> </div>
@ -246,59 +161,146 @@ export function ReviewStep({
); );
} }
function ReviewItem({ q, index }: { q: any; index: number }) { function QuestionReviewBlock({
q,
index,
typeDefinitions,
}: {
q: any;
index: number;
typeDefinitions: QuestionTypeDefinition[];
}) {
const def = typeDefinitions.find((d) => d.id === q.questionTypeDefinitionId);
const badge =
def != null
? `${def.display_name}${def.is_system ? "" : ` · ${def.key}`}`
: q.questionTypeDefinitionId != null
? `Type #${q.questionTypeDefinitionId}`
: "No type selected";
const isDynamic = def != null && definitionUsesDynamicPayload(def);
const legacy = def != null ? legacyQuestionTypeFromDefinition(def) : null;
const schemaRows: { key: string; label: string; value: string }[] = [];
if (isDynamic && def) {
const vals = (q.dynamicFieldValues ?? {}) as Record<string, string>;
for (const r of def.stimulus_schema) {
const k = `stimulus:${r.id}`;
schemaRows.push({
key: k,
label: r.label?.trim() || r.kind,
value: vals[k] ?? "",
});
}
for (const r of def.response_schema) {
const k = `response:${r.id}`;
schemaRows.push({
key: k,
label: r.label?.trim() || r.kind,
value: vals[k] ?? "",
});
}
}
return ( return (
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"> <Card className="relative overflow-hidden rounded-2xl border-grayScale-50 bg-white shadow-soft">
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" /> <div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
<div className="px-5 pb-7 pt-2 space-y-6"> <div className="space-y-4 px-5 pb-6 pt-4 pl-7">
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-50 pb-3">
<div className="flex items-center gap-3"> <span className="text-base font-bold text-grayScale-500">
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
<span className="font-bold text-grayScale-500 text-base">
Question {index + 1} Question {index + 1}
</span> </span>
<span className="rounded-md bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">
{badge}
</span>
</div> </div>
<Button
variant="ghost" <div className="space-y-2">
size="icon" <span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
className="text-brand-500 hover:bg-brand-50 rounded-lg" Question text
> </span>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
<div className="md:col-span-8 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
QUESTION PROMPT
</label>
<Input <Input
value={q.text} value={q.text}
readOnly readOnly
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700" className="min-h-[52px] rounded-xl border-grayScale-200 bg-white px-4 py-3 text-base font-medium text-grayScale-700"
placeholder="e.g. How long have you been studying English?"
/> />
</div> </div>
<div className="md:col-span-4 space-y-3">
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest"> {isDynamic && schemaRows.length > 0 ? (
VOICE PROMPT <div className="space-y-3">
</label> <span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
<VoicePrompt Stimulus and response fields
src={q.voicePrompt} </span>
filename={q.voicePrompt} <ul className="space-y-2 text-sm">
onRemove={() => {}} {schemaRows.map((row) => (
/> <li key={row.key} className="rounded-lg border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
<span className="block text-[10px] font-bold uppercase tracking-wide text-grayScale-500">
{row.label}
</span>
<span className="mt-1 block break-all font-mono text-xs text-grayScale-800">
{row.value?.trim() ? row.value : "—"}
</span>
</li>
))}
</ul>
</div> </div>
) : null}
{legacy === "MCQ" && (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
Choices
</span>
<ul className="space-y-1.5 text-sm">
{(q.mcqOptions ?? []).map(
(opt: { text?: string; isCorrect?: boolean }, j: number) =>
opt.text?.trim() ? (
<li
key={j}
className={
opt.isCorrect
? "font-medium text-green-700"
: "text-grayScale-600"
}
>
{opt.isCorrect ? "✓ " : ""}
{opt.text}
</li>
) : null,
)}
</ul>
</div> </div>
<div className="md:w-1/3 space-y-3"> )}
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
SAMPLE ANSWER PROMPT {legacy === "TRUE_FALSE" && (
</label> <p className="text-sm text-grayScale-700">
<VoicePrompt <span className="font-semibold">Correct:</span>{" "}
src={q.sampleAnswer} {q.trueFalseCorrect !== false ? "True" : "False"}
filename={q.sampleAnswer} </p>
onRemove={() => {}} )}
/>
{legacy === "SHORT_ANSWER" && (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
Acceptable answers
</span>
<ul className="list-disc space-y-1 pl-5 text-sm text-grayScale-700">
{(q.shortAnswers ?? [])
.filter((s: string) => s?.trim())
.map((s: string, j: number) => (
<li key={j}>{s}</li>
))}
</ul>
</div> </div>
)}
{def != null && legacy == null && !isDynamic ? (
<p className="text-xs text-amber-800">
This type has no schema and is not mapped to a classic MCQ / truefalse /
short-answer form. Publish still sends the best-effort payload from the
builder.
</p>
) : null}
</div> </div>
</Card> </Card>
); );

View File

@ -1,68 +1,120 @@
import { Upload, ArrowRight } from "lucide-react"; import { useRef, useState, type ChangeEvent } from "react";
import { Link } from "react-router-dom";
import { Upload, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card"; import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input"; import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api";
interface ScenarioStepProps { interface ScenarioStepProps {
formData: any; formData: any;
setFormData: (data: any) => void; setFormData: (data: any) => void;
nextStep: () => void; nextStep: () => void;
prevStep: () => void; cancelHref: string;
} }
export function ScenarioStep({ export function ScenarioStep({
formData, formData,
setFormData, setFormData,
nextStep, nextStep,
prevStep, cancelHref,
}: ScenarioStepProps) { }: ScenarioStepProps) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploadingBanner, setUploadingBanner] = useState(false);
const onBannerFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setUploadingBanner(true);
try {
const res = await uploadImageFile(file);
const url = res.data?.data?.url?.trim();
if (!url) throw new Error("Missing URL");
setFormData({ ...formData, storyImageUrl: url });
toast.success("Story image uploaded");
} catch {
toast.error("Could not upload image");
} finally {
setUploadingBanner(false);
}
};
const canContinue =
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1 px-2"> <div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700"> <h2 className="text-2xl font-extrabold text-grayScale-700">
Define Scenario Details Practice details
</h2> </h2>
<p className="text-grayScale-400 text-lg"> <p className="text-grayScale-400 text-lg">
Set the scene and context for this English practice session. Story fields and question set options used when saving the practice.
</p> </p>
</div> </div>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-1">
<label className="text-sm text-grayScale-700">
Practice Banner Image
</label>
<p className="text-xs pb-2 text-grayScale-400">
This image will appear as the background for the scenario.
</p>
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
<Upload className="h-6 w-6" />
</div>
<p className="text-sm">
<span className="text-grayScale-700">
Click to upload or drag and drop
</span>
</p>
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
SVG, PNG, JPG (MAX 5MB)
</p>
<Button
variant="outline"
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
>
Browse Files
</Button>
</div>
</div>
</Card>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white"> <Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Practice Title <span className="text-red-500">*</span> Story image <span className="text-grayScale-400">(optional)</span>
</label> </label>
<Input <Input
placeholder="e.g., Ordering Coffee at a Cafe" value={formData.storyImageUrl ?? ""}
onChange={(e) =>
setFormData({ ...formData, storyImageUrl: e.target.value })
}
placeholder="Image URL"
className="h-10 rounded-lg border-grayScale-200 font-mono text-xs"
/>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={onBannerFile}
/>
<Button
type="button"
variant="outline"
disabled={uploadingBanner}
onClick={() => fileRef.current?.click()}
className="gap-2"
>
{uploadingBanner ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Upload image
</Button>
</div>
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
<input
type="checkbox"
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
checked={Boolean(formData.shuffleQuestions)}
onChange={(e) =>
setFormData({
...formData,
shuffleQuestions: e.target.checked,
})
}
/>
<span>Shuffle questions in the set</span>
</label>
</Card>
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Practice title <span className="text-red-500">*</span>
</label>
<Input
placeholder="e.g. Ordering coffee at a cafe"
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white" className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
value={formData.title} value={formData.title}
onChange={(e) => onChange={(e) =>
@ -72,11 +124,11 @@ export function ScenarioStep({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Scenario Description <span className="text-red-500">*</span> Story description <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Textarea <Textarea
placeholder="Describe the setting..." placeholder="Describe the scenario…"
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white" className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
maxLength={1000} maxLength={1000}
value={formData.description} value={formData.description}
@ -91,26 +143,34 @@ export function ScenarioStep({
{formData.description.length} / 1000 {formData.description.length} / 1000
</div> </div>
</div> </div>
<span className="text-xs text-grayScale-500"> </div>
Provide context for the AI and the student. Be specific about the <div className="space-y-2">
location and the goal. <label className="text-sm font-medium text-grayScale-700">
</span> Quick tips <span className="text-grayScale-400">(optional)</span>
</label>
<Textarea
value={formData.tips ?? ""}
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips)"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>
</div> </div>
</Card> </Card>
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-between pt-4">
<Button <Button variant="outline" className="h-10 px-6" asChild>
onClick={prevStep} <Link to={cancelHref}>Cancel</Link>
variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
>
Back
</Button> </Button>
<Button <Button
type="button"
onClick={nextStep} onClick={nextStep}
disabled={!formData.title || !formData.description} disabled={!canContinue}
className="h-10 rounded-[6px] bg-brand-500 px-8 " className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
> >
Next: Persona <ArrowRight className="ml-2 h-4 w-4" /> Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>