-
-
- Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
+
+ Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
- >
+
)}
-
-
Tips (Optional)
-
setFormData((prev) => ({ ...prev, tips: e.target.value }))}
- placeholder="Helpful tip for learners"
- />
+
+
+ Tips (opt.)
+ setFormData((prev) => ({ ...prev, tips: e.target.value }))}
+ placeholder="Short tip"
+ className="h-9 text-sm"
+ />
+
+
+ Explanation (opt.)
+
-
- Explanation (Optional)
-
-
- {/* Actions */}
-
-
diff --git a/src/pages/content-management/components/VideoCard.tsx b/src/pages/content-management/components/VideoCard.tsx
index 16e73a6..ef3080c 100644
--- a/src/pages/content-management/components/VideoCard.tsx
+++ b/src/pages/content-management/components/VideoCard.tsx
@@ -1,5 +1,13 @@
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 {
Dialog,
@@ -32,14 +40,16 @@ interface VideoCardProps {
*/
videoUrl?: string;
/**
- * When true, shows edit/delete in the top-right of the thumbnail (same
- * hover pattern as module cards) and removes the footer + overflow menu.
+ * When true, shows edit/delete (and optional view practices) in the top-right
+ * of the thumbnail on hover, and removes the footer + overflow menu.
*/
hoverModuleActions?: boolean;
onEdit?: () => void;
onDelete?: () => void;
/** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */
onAddPractice?: () => void;
+ /** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
+ onViewPractices?: () => void;
onPublish?: () => void;
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null;
@@ -56,6 +66,7 @@ export function VideoCard({
onDelete,
onPublish,
onAddPractice,
+ onViewPractices,
hoverModuleActions = false,
description,
}: VideoCardProps) {
@@ -128,10 +139,25 @@ export function VideoCard({
!useGradient && "bg-grayScale-100",
)}
>
- {hoverModuleActions && (onEdit || onDelete) ? (
+ {hoverModuleActions && (onEdit || onDelete || onViewPractices) ? (
+ {onViewPractices ? (
+
{
+ e.stopPropagation();
+ onViewPractices();
+ }}
+ >
+
+
+ ) : null}
{onEdit ? (
void;
navigate: (path: string) => void;
level: string;
- isModuleContext?: boolean;
- isCourseContext?: boolean;
}
+/**
+ * Module / lesson entry: fields that map to POST /practices and POST /question-sets.
+ */
export function ContextStep({
formData,
setFormData,
nextStep,
navigate,
level,
- isModuleContext,
- isCourseContext,
}: ContextStepProps) {
+ const storyFileRef = useRef(null);
+ const [uploadingStory, setUploadingStory] = useState(false);
+
+ const handleStoryImageFile = async (event: ChangeEvent) => {
+ 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 (
-
+
- Step 1: Context Definition
+ Practice details
- 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.
@@ -40,110 +68,111 @@ export function ContextStep({
-
- {/* Program Field */}
-
-
- Program{" "}
-
- (Auto-selected)
-
+
+
+
+ Practice title *
-
-
-
-
-
-
+
+ setFormData({ ...formData, title: e.target.value })
+ }
+ placeholder="e.g. Lesson 12 conversation drill"
+ className="h-11 rounded-xl border-grayScale-200"
+ />
- {/* Course Field */}
-
-
- Course{" "}
-
- (Auto-selected)
-
+
+
+ Story description *
-
-
-
-
-
-
+
- {/* Select Module Field */}
- {(isModuleContext || isCourseContext) && (
-
-
- Select Module
-
-
-
-
-
-
-
-
- Select the specific learning module this practice will reinforce.
-
-
- )}
+
+
+ Quick tips (optional)
+
+
- {/* Select Video Field (Conditional) */}
- {isModuleContext && (
-
-
- Select Video
-
-
-
-
-
-
-
-
- Select the specific learning module this practice will reinforce.
-
-
- )}
+
+
+ Story image (optional)
+
+
+ setFormData({ ...formData, storyImageUrl: e.target.value })
+ }
+ placeholder="https://… or upload"
+ className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
+ />
+
+ storyFileRef.current?.click()}
+ className="gap-2"
+ >
+ {uploadingStory ? (
+
+ ) : (
+
+ )}
+ Upload image
+
+
+
+
+
+ setFormData({
+ ...formData,
+ shuffleQuestions: e.target.checked,
+ })
+ }
+ />
+ Shuffle questions in the set
+
navigate(`/new-content/learn-english/${level}/courses`)
@@ -152,11 +181,12 @@ export function ContextStep({
Cancel
- Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
-
+ Next: Questions
diff --git a/src/pages/content-management/components/practice-steps/QuestionsStep.tsx b/src/pages/content-management/components/practice-steps/QuestionsStep.tsx
index 77c6742..47f7f73 100644
--- a/src/pages/content-management/components/practice-steps/QuestionsStep.tsx
+++ b/src/pages/content-management/components/practice-steps/QuestionsStep.tsx
@@ -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 { Card } from "../../../../components/ui/card";
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
,
+ mcqOptions: defaultMcqOptions(),
+ trueFalseCorrect: true,
+ shortAnswers: [""],
+ };
+}
interface QuestionsStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
prevStep: () => void;
+ typeDefinitions: QuestionTypeDefinition[];
+ definitionsLoading: boolean;
+ definitionsError: string | null;
}
export function QuestionsStep({
@@ -16,64 +47,365 @@ export function QuestionsStep({
setFormData,
nextStep,
prevStep,
+ typeDefinitions,
+ definitionsLoading,
+ definitionsError,
}: QuestionsStepProps) {
- const addQuestion = () => {
- const newQuestion = {
- id: `q${formData.questions.length + 1}`,
- text: "",
- type: "Speaking",
- voicePrompt: "upload_audio.mp3",
- sampleAnswer: "upload_audio.mp3",
+ const applyDefinitionToQuestion = (
+ index: number,
+ definitionId: number,
+ defs: QuestionTypeDefinition[],
+ ) => {
+ const def = defs.find((d) => d.id === definitionId);
+ 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({
...formData,
- questions: [...formData.questions, newQuestion],
+ questions: [...formData.questions, row],
});
};
+ const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
+ if (definitionUsesDynamicPayload(def)) {
+ return (
+
+
+ Image / Audio slots use upload or URL import (
+ POST /files/upload
+ ). Others: URL, text, or JSON.
+
+ {def.stimulus_schema.length > 0 ? (
+
+
Stimulus
+ {def.stimulus_schema.map((row) => (
+
+
+ setDynamicValue(i, `stimulus:${row.id}`, next)
+ }
+ />
+
+ ))}
+
+ ) : null}
+ {def.response_schema.length > 0 ? (
+
+
Response
+ {def.response_schema.map((row) => (
+
+
+ setDynamicValue(i, `response:${row.id}`, next)
+ }
+ />
+
+ ))}
+
+ ) : null}
+
+ );
+ }
+
+ const legacy = legacyQuestionTypeFromDefinition(def);
+ if (legacy === "MCQ") {
+ return (
+
+
+ Choices (mark one correct)
+
+
+
+ );
+ }
+
+ if (legacy === "TRUE_FALSE") {
+ return (
+
+ );
+ }
+
+ if (legacy === "SHORT_ANSWER") {
+ return (
+
+
+ Acceptable answers
+
+ {(q.shortAnswers ?? [""]).map((line: string, j: number) => (
+
+ {
+ 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"
+ />
+ {
+ 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
+
+
+ ))}
+
{
+ const newQuestions = [...formData.questions];
+ newQuestions[i].shortAnswers = [
+ ...(newQuestions[i].shortAnswers ?? [""]),
+ "",
+ ];
+ setFormData({ ...formData, questions: newQuestions });
+ }}
+ >
+ Add acceptable answer
+
+
+ );
+ }
+
+ return (
+
+ This definition has no schema rows and is not mapped to MCQ / True‑False /
+ Short answer. It will be submitted as{" "}
+ DYNAMIC with an empty payload.
+
+ );
+ };
+
return (
-
- Create Practice Questions
-
+
Questions
- Define the dialogue flow and interactions for this scenario.
+ Question types are loaded from{" "}
+
+ GET /questions/type-definitions
+
+ . Pick a type per row, then fill the fields required for that definition.
+
+ {definitionsError ? (
+
+ {definitionsError}
+
+ ) : null}
+
+ {definitionsLoading ? (
+
Loading question types…
+ ) : null}
+
- {formData.questions.map((q: any, i: number) => (
-
-
-
-
-
-
-
+ {formData.questions.map((q: any, i: number) => {
+ const def = typeDefinitions.find(
+ (d) => d.id === q.questionTypeDefinitionId,
+ );
+ return (
+
+
+
+
+
Question {i + 1}
+ {
+ const newQuestions = formData.questions.filter(
+ (item: any) => item.id !== q.id,
+ );
+ if (newQuestions.length > 0) {
+ 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] });
+ }}
+ >
+
+
-
{
- const newQuestions = formData.questions.filter(
- (item: any) => item.id !== q.id,
- );
- setFormData({ ...formData, questions: newQuestions });
- }}
- >
-
-
-
-
-
-
- QUESTION PROMPT
+
+
+
+ Question type
+
+
+ {def?.description ? (
+
{def.description}
+ ) : null}
+
+
+
+
+ Question text
-
-
-
- VOICE PROMPT
-
- {
- const newQuestions = [...formData.questions];
- newQuestions[i].voicePrompt = "";
- setFormData({ ...formData, questions: newQuestions });
- }}
+ className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
+ placeholder="Question prompt for learners"
/>
+
+ {def ? renderTypeSpecificFields(q, i, def) : null}
-
-
- SAMPLE ANSWER PROMPT
-
- {
- const newQuestions = [...formData.questions];
- newQuestions[i].sampleAnswer = "";
- setFormData({ ...formData, questions: newQuestions });
- }}
- />
-
-
-
- ))}
+
+ );
+ })}
+
{" "}
- Add New Question
-
-
- {" "}
- Add Tips
+
+ Add question
+
Next: Review
diff --git a/src/pages/content-management/components/practice-steps/ReviewStep.tsx b/src/pages/content-management/components/practice-steps/ReviewStep.tsx
index cb87758..4def1d3 100644
--- a/src/pages/content-management/components/practice-steps/ReviewStep.tsx
+++ b/src/pages/content-management/components/practice-steps/ReviewStep.tsx
@@ -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 { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
+import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from "../../../../components/ui/avatar";
-import { PERSONAS } from "./constants";
-import { VoicePrompt } from "./VoicePrompt";
+ definitionUsesDynamicPayload,
+ legacyQuestionTypeFromDefinition,
+} from "../../../../lib/learnEnglishDefinitionQuestion";
interface ReviewStepProps {
formData: any;
- selectedPersona: string | null;
prevStep: () => void;
- setIsPublished: (val: boolean) => void;
- isModuleContext?: boolean;
+ parentSummary: string | null;
+ typeDefinitions: QuestionTypeDefinition[];
+ canPublish: boolean;
+ submitting: boolean;
+ onSaveDraft: () => void;
+ onPublish: () => void;
}
export function ReviewStep({
formData,
- selectedPersona,
prevStep,
- setIsPublished,
- isModuleContext,
+ parentSummary,
+ typeDefinitions,
+ canPublish,
+ submitting,
+ onSaveDraft,
+ onPublish,
}: ReviewStepProps) {
- const persona = PERSONAS.find((p) => p.id === selectedPersona);
-
return (
- Review Practice Questions
+ Review
- {/* 1. Basic Info Card (Image 1436.1) */}
-
-
-
- Basic Information
-
-
-
- Edit
-
+ {!canPublish && (
+
+
Missing parent for the API
+
+ Open Add Practice from a course, module, or lesson so parent IDs are
+ in the URL.
+
+
+ )}
+
+
+
+
+ Practice
+
- {/* Gradient Divider */}
-
-
-
+
+
+
-
-
- {formData.title || "Business English 101: Communication"}
+
+
+ {formData.title || "Untitled"}
-
-
- Program:{" "}
- {formData.program}
-
-
- Course:{" "}
- {formData.course}
-
-
- Module:{" "}
-
- Module 101
+
+ {parentSummary ? (
+
+ Link:{" "}
+ {parentSummary}
-
-
-
-
-
-
- Persona
-
-
-
-
- P
-
-
- {persona?.name || "Alex Johnson"}
-
+ ) : (
+ "—"
+ )}
+
+ {formData.shuffleQuestions ? (
+
Shuffle questions: on
+ ) : null}
- {/* 2. Tips Section (Image 1436.1) */}
-
- TIPS / GUIDANCE
+
+ Quick tips
-
-
- {formData.tips ||
- "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
+
+
+ {formData.tips?.trim() || "—"}
- {isModuleContext ? (
- /* 3. Split Questions & Answers Layout (Image 1413.1) */
-
- {/* Left Column: Questions */}
-
-
-
- Questions
-
-
- {formData.questions.length}
-
-
-
- {formData.questions.map((q: any, i: number) => (
-
-
- {(i + 1).toString().padStart(2, "0")}
-
-
-
-
- TEXT PROMPT
-
-
- {q.text}
-
-
-
-
- VOICE PROMPT
-
-
-
-
-
- ))}
-
-
-
- {/* Right Column: Answers */}
-
-
-
-
Answers
-
- {formData.questions.length}
-
-
-
-
- Edit
-
-
-
- {formData.questions.map((q: any, i: number) => (
-
-
- {(i + 1).toString().padStart(2, "0")}
-
-
-
- VOICE PROMPT
-
-
-
-
- ))}
-
-
-
- ) : (
- /* Original Non-Module View */
-
+
+
Questions
+
{formData.questions.map((q: any, i: number) => (
-
+
))}
- )}
+
- {/* Action Footer */}
Back
+ {submitting ? (
+
+ ) : null}
Save as Draft
setIsPublished(true)}
- 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"
+ disabled={submitting || !canPublish}
+ 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 ? (
+
+ ) : (
+
+ )}
Publish Now
@@ -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
;
+ 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 (
-
-
-
-
-
-
-
- Question {index + 1}
-
-
-
-
-
+
+
+
+
+
+ Question {index + 1}
+
+
+ {badge}
+
-
-
-
- QUESTION PROMPT
-
-
-
-
-
- VOICE PROMPT
-
- {}}
- />
-
-
-
-
- SAMPLE ANSWER PROMPT
-
-
{}}
+
+
+
+ Question text
+
+
+
+ {isDynamic && schemaRows.length > 0 ? (
+
+
+ Stimulus and response fields
+
+
+ {schemaRows.map((row) => (
+ -
+
+ {row.label}
+
+
+ {row.value?.trim() ? row.value : "—"}
+
+
+ ))}
+
+
+ ) : null}
+
+ {legacy === "MCQ" && (
+
+
+ Choices
+
+
+ {(q.mcqOptions ?? []).map(
+ (opt: { text?: string; isCorrect?: boolean }, j: number) =>
+ opt.text?.trim() ? (
+ -
+ {opt.isCorrect ? "✓ " : ""}
+ {opt.text}
+
+ ) : null,
+ )}
+
+
+ )}
+
+ {legacy === "TRUE_FALSE" && (
+
+ Correct:{" "}
+ {q.trueFalseCorrect !== false ? "True" : "False"}
+
+ )}
+
+ {legacy === "SHORT_ANSWER" && (
+
+
+ Acceptable answers
+
+
+ {(q.shortAnswers ?? [])
+ .filter((s: string) => s?.trim())
+ .map((s: string, j: number) => (
+ - {s}
+ ))}
+
+
+ )}
+
+ {def != null && legacy == null && !isDynamic ? (
+
+ This type has no schema and is not mapped to a classic MCQ / true–false /
+ short-answer form. Publish still sends the best-effort payload from the
+ builder.
+
+ ) : null}
);
diff --git a/src/pages/content-management/components/practice-steps/ScenarioStep.tsx b/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
index f7f7d7c..eaecbf7 100644
--- a/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
+++ b/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
@@ -1,69 +1,121 @@
-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 { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
+import { toast } from "sonner";
+import { uploadImageFile } from "../../../../api/files.api";
interface ScenarioStepProps {
formData: any;
setFormData: (data: any) => void;
nextStep: () => void;
- prevStep: () => void;
+ cancelHref: string;
}
export function ScenarioStep({
formData,
setFormData,
nextStep,
- prevStep,
+ cancelHref,
}: ScenarioStepProps) {
+ const fileRef = useRef
(null);
+ const [uploadingBanner, setUploadingBanner] = useState(false);
+
+ const onBannerFile = async (event: ChangeEvent) => {
+ 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 (
- Define Scenario Details
+ Practice details
- Set the scene and context for this English practice session.
+ Story fields and question set options used when saving the practice.
-
-
-
- Practice Banner Image
-
-
- This image will appear as the background for the scenario.
-
-
-
-
-
-
-
- Click to upload or drag and drop
-
-
-
- SVG, PNG, JPG (MAX 5MB)
-
-
- Browse Files
-
-
-
-
+
- Practice Title *
+ Story image (optional)
+ setFormData({ ...formData, storyImageUrl: e.target.value })
+ }
+ placeholder="Image URL"
+ className="h-10 rounded-lg border-grayScale-200 font-mono text-xs"
+ />
+
+ fileRef.current?.click()}
+ className="gap-2"
+ >
+ {uploadingBanner ? (
+
+ ) : (
+
+ )}
+ Upload image
+
+
+
+
+
+ setFormData({
+ ...formData,
+ shuffleQuestions: e.target.checked,
+ })
+ }
+ />
+ Shuffle questions in the set
+
+
+
+
+
+
+ Practice title *
+
+
setFormData({ ...formData, title: e.target.value })
@@ -72,11 +124,11 @@ export function ScenarioStep({
- Scenario Description *
+ Story description *
-
- Provide context for the AI and the student. Be specific about the
- location and the goal.
-
+
+
+
+ Quick tips (optional)
+
+
+
-
- Back
+
+ Cancel
- Next: Persona
+ Next: Questions