@tinyweb_dev/oe-exam-sdk 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/components/exams/take/components/QuestionRenderer.js +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  4. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  5. package/dist/components/exams/take/types.d.ts +12 -5
  6. package/dist/components/exams/take/utils/answer-transformers.js +1 -1
  7. package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
  8. package/dist/components/exams/take/utils/question-transformers.js +29 -53
  9. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
  10. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  11. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
  12. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
  13. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
  14. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
  15. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  16. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  17. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  18. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  19. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  20. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  21. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  22. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
  23. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  24. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  25. package/dist/components/results/renderers/review-question-body-dedicated.js +3 -3
  26. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  27. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  28. package/dist/shared/lib/utils/question-reverse-transform.js +68 -3
  29. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
  30. package/package.json +1 -1
@@ -55,7 +55,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
55
55
  }
56
56
  case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
57
57
  const data = transformChooseTheCorrectAnswerGroup(questions);
58
- return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
58
+ return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
59
59
  }
60
60
  case 'CHOOSE_CORRECT_ADJECTIVE': {
61
61
  const data = transformChooseCorrectAdjective(questions);
@@ -42,7 +42,7 @@ function normalizeRenderableText(value) {
42
42
  }
43
43
  // Example Section Component
44
44
  function ExampleSection({ example }) {
45
- return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
45
+ return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), example.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: example.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
46
46
  const isCorrect = option.id === example.correctOptionId;
47
47
  return (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-40 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", isCorrect
48
48
  ? "bg-green-500 border-green-500 text-white"
@@ -99,7 +99,7 @@ function QuestionItem({ question, selectedOptionIds, onSelect, isReviewMode, id,
99
99
  }
100
100
  return selectedOptionIds.includes(optionId) || correctOptionIds.includes(optionId);
101
101
  };
102
- return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
102
+ return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
103
103
  ? "bg-blue-600 border-blue-600 text-white"
104
104
  : "border-gray-300 bg-white text-transparent"), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }) })] }, option.id))) })) : (_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: normalizeRenderableText(option.text) }), _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 ${isMultipleAnswers ? 'rounded-md' : 'rounded-full'} ${getIndicatorStyle(option.id)}`, children: showIndicatorMark(option.id) && (_jsx("div", { className: `${isMultipleAnswers ? 'h-2.5 w-2.5 rounded-sm' : 'h-2 w-2 rounded-full'} bg-white` })) })] }, option.id))) }))] }));
105
105
  }
@@ -10,8 +10,8 @@ interface MoversListenAndWriteRendererProps {
10
10
  exampleText?: string;
11
11
  exampleHighlight?: string;
12
12
  questions: FillBlankQuestion[];
13
- answers: Record<string, string>;
14
- onAnswerChange: (questionId: string, value: string) => void;
13
+ answers: Record<string, unknown>;
14
+ onAnswerChange: (questionId: string, value: string[]) => void;
15
15
  isReviewMode?: boolean;
16
16
  showHints?: boolean;
17
17
  groupContent?: string;
@@ -5,22 +5,68 @@ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-y
5
5
  import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
6
6
  import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
7
7
  import CambridgeYleExampleText from '../../../../../components/themes/cambridge-yle/CambridgeYleExampleText';
8
- /** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
8
+ import { countBlanks, formatAcceptedAnswers, getPrimaryAnswer, isBlankCorrect, toBlankValues, } from '../../../../../shared/lib/utils/fill-in-blank';
9
+ /** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
9
10
  function renderTextWithLineBreaks(text) {
10
11
  const lines = text.split('\n');
11
12
  return lines.map((line, index) => (_jsxs(Fragment, { children: [index > 0 ? _jsx("br", {}) : null, line] }, index)));
12
13
  }
14
+ function blankInputClassName({ isExampleQ, isReviewMode, isCorrect, isWrong, }) {
15
+ if (isExampleQ)
16
+ return 'border-amber-300 bg-amber-100/50 text-amber-800 disabled:opacity-100';
17
+ if (isReviewMode) {
18
+ if (isCorrect)
19
+ return 'border-emerald-400 bg-emerald-50 text-emerald-800';
20
+ if (isWrong)
21
+ return 'border-rose-400 bg-rose-50 text-rose-800';
22
+ return 'border-slate-200 bg-slate-50 text-slate-500';
23
+ }
24
+ return 'border-blue-200 bg-blue-50/30 text-blue-900 focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-100';
25
+ }
13
26
  export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, }) {
14
27
  const hasGroupContent = !!(audioUrl || imageUrl || groupContent || exampleText);
15
28
  return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar relative", children: [_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: isReviewMode })] }), hasGroupContent && (_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-0 sticky top-0 z-20 bg-white/95 backdrop-blur pb-2 shadow-sm border-b border-gray-100", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), _jsxs("div", { className: "max-h-[35vh] overflow-y-auto custom-scrollbar pr-2 flex flex-col gap-3", children: [imageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[200px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsxs("div", { className: "rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 shadow-inner", children: [groupTitle && _jsx("h3", { className: "mb-2 font-semibold text-gray-800", children: groupTitle }), groupContent] }))] }), exampleText && (_jsx(CambridgeYleExampleText, { text: exampleText, highlightedWord: exampleHighlight || '' }))] })), _jsxs("div", { className: "flex flex-col gap-4 p-3 sm:gap-6 sm:pb-6 sm:px-6", children: [title && (_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title })), _jsx("div", { className: "flex flex-col gap-6 sm:gap-8", children: questions.map((question) => {
16
29
  const isExampleQ = question.isExample || false;
17
30
  const isDisabled = isReviewMode || isExampleQ;
18
- const displayValue = isExampleQ && question.correctAnswer ? question.correctAnswer : (answers[question.id] || '');
19
- const rawUserAnswer = (answers[question.id] || '').trim().toLowerCase();
20
- const rawCorrectAnswer = (question.correctAnswer || '').trim().toLowerCase();
21
- const hasAnswer = !!rawUserAnswer;
22
- const isCorrect = isReviewMode && !isExampleQ && hasAnswer && rawUserAnswer === rawCorrectAnswer;
23
- const isWrong = isReviewMode && !isExampleQ && hasAnswer && rawUserAnswer !== rawCorrectAnswer;
31
+ const blankCount = countBlanks(question.segments);
32
+ // Examples are pre-filled with the first accepted answer; everyone
33
+ // else shows what the student typed.
34
+ const displayValues = isExampleQ
35
+ ? question.acceptedAnswers.map(getPrimaryAnswer)
36
+ : toBlankValues(answers[question.id], blankCount);
37
+ const blankStatuses = displayValues.map((value, index) => {
38
+ const hasValue = !!value.trim();
39
+ const matches = isBlankCorrect(value, question.acceptedAnswers[index]);
40
+ return {
41
+ hasValue,
42
+ isCorrect: isReviewMode && !isExampleQ && hasValue && matches,
43
+ isWrong: isReviewMode && !isExampleQ && hasValue && !matches,
44
+ };
45
+ });
46
+ const hasAnswer = blankStatuses.some((status) => status.hasValue);
47
+ const isCorrect = isReviewMode &&
48
+ !isExampleQ &&
49
+ hasAnswer &&
50
+ blankStatuses.every((status) => status.isCorrect);
51
+ const isWrong = isReviewMode && !isExampleQ && hasAnswer && !isCorrect;
52
+ const setBlankValue = (blankIndex, value) => {
53
+ const next = toBlankValues(answers[question.id], blankCount);
54
+ next[blankIndex] = value;
55
+ onAnswerChange(question.id, next);
56
+ };
57
+ const renderBlankInput = (blankIndex) => {
58
+ const status = blankStatuses[blankIndex] || {
59
+ hasValue: false,
60
+ isCorrect: false,
61
+ isWrong: false,
62
+ };
63
+ return (_jsx("span", { className: "mx-1 inline-block w-[7.5rem] max-w-[40%] min-w-[5.5rem] align-baseline sm:w-[8.5rem]", children: _jsx("input", { type: "text", value: displayValues[blankIndex] || '', onChange: (e) => setBlankValue(blankIndex, e.target.value), disabled: isDisabled, placeholder: isExampleQ ? '' : '...', className: `w-full rounded-md border-2 px-2 py-1 text-center text-sm font-semibold outline-none transition-all sm:text-base ${blankInputClassName({
64
+ isExampleQ,
65
+ isReviewMode,
66
+ isCorrect: status.isCorrect,
67
+ isWrong: status.isWrong,
68
+ })} ${isDisabled ? 'disabled:cursor-not-allowed disabled:opacity-75' : ''}` }) }));
69
+ };
24
70
  return (_jsxs("div", { id: `question-${question.id}`, className: `flex flex-col gap-4 rounded-2xl border p-4 sm:p-6 transition-all duration-200 hover:shadow-md ${isExampleQ
25
71
  ? 'border-amber-300 bg-amber-50/60 shadow-xs'
26
72
  : 'border-slate-200/80 bg-white shadow-xs hover:border-blue-300'}`, children: [_jsxs("div", { className: "flex items-center justify-between border-b border-slate-100 pb-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: `inline-flex items-center justify-center rounded-full px-3.5 py-1 text-sm font-bold text-white shadow-xs ${isExampleQ
@@ -31,12 +77,10 @@ export function MoversListenAndWriteRenderer({ partNumber, partName, questionCou
31
77
  : isWrong
32
78
  ? 'bg-rose-500'
33
79
  : 'bg-[#001590]'
34
- : 'bg-[#001590]'}`, children: isExampleQ ? ` dụ` : `${question.questionNumber}.` }), isExampleQ && (_jsx("span", { className: "rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: "Example" }))] }), isReviewMode && !isExampleQ && (_jsx("div", { children: isCorrect ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 border border-emerald-200", children: "\u2713 \u0110\u00FAng" })) : isWrong ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-0.5 text-xs font-semibold text-rose-700 border border-rose-200", children: "\u2715 Ch\u01B0a \u0111\u00FAng" })) : (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600", children: "Ch\u01B0a tr\u1EA3 l\u1EDDi" })) }))] }), question.audioUrl && (_jsx("div", { className: "w-full my-1", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), question.imageUrl && (_jsx("div", { className: "flex items-center justify-center rounded-xl bg-slate-50/80 p-3 border border-slate-100 max-w-md mx-auto w-full", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: `Question ${question.questionNumber} illustration`, className: "max-h-[200px] sm:max-h-[220px] w-auto rounded-lg object-contain drop-shadow-xs" }) })), _jsxs("div", { className: "text-base sm:text-lg leading-relaxed text-slate-800 pt-1", children: [question.textBefore && (_jsx("span", { className: "font-medium", children: renderTextWithLineBreaks(question.textBefore) })), _jsx("span", { className: "mx-1 inline-block w-[7.5rem] max-w-[40%] min-w-[5.5rem] align-baseline sm:w-[8.5rem]", children: _jsx("input", { type: "text", value: displayValue, onChange: (e) => onAnswerChange(question.id, e.target.value), disabled: isDisabled, placeholder: isExampleQ ? '' : '...', className: `w-full rounded-md border-2 px-2 py-1 text-center text-sm font-semibold outline-none transition-all sm:text-base ${isExampleQ
35
- ? 'border-amber-300 bg-amber-100/50 text-amber-800 disabled:opacity-100'
36
- : isReviewMode
37
- ? isCorrect
38
- ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
39
- : 'border-rose-400 bg-rose-50 text-rose-800'
40
- : 'border-blue-200 bg-blue-50/30 text-blue-900 focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-100'} ${isDisabled ? 'disabled:cursor-not-allowed disabled:opacity-75' : ''}` }) }), question.textAfter && (_jsx("span", { className: "font-medium", children: renderTextWithLineBreaks(question.textAfter) })), question.verbHint && (_jsxs("span", { className: "ml-1 italic text-slate-500 font-normal", children: ["(", question.verbHint, ")"] }))] }), showHints && question.hints && question.hints.length > 0 && !isReviewMode && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100", children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: "G\u1EE3i \u00FD:" }), question.hints.map((hint, idx) => (_jsx("button", { type: "button", onClick: () => onAnswerChange(question.id, hint), className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 shadow-2xs transition-all hover:bg-blue-600 hover:text-white hover:border-blue-600 active:scale-95", children: hint }, idx)))] })), isReviewMode && !isExampleQ && question.correctAnswer && (_jsx("div", { className: "mt-1 rounded-lg bg-emerald-50/80 p-2.5 text-sm border border-emerald-200/80", children: _jsxs("span", { className: "font-semibold text-emerald-800", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("span", { className: "font-bold underline", children: question.correctAnswer })] }) }))] }, question.id));
80
+ : 'bg-[#001590]'}`, children: isExampleQ ? `Ví dụ` : `${question.questionNumber}.` }), isExampleQ && (_jsx("span", { className: "rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: "Example" }))] }), isReviewMode && !isExampleQ && (_jsx("div", { children: isCorrect ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 border border-emerald-200", children: "\u00E2\u0153\u201C \u00C4\u0090\u00C3\u00BAng" })) : isWrong ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-0.5 text-xs font-semibold text-rose-700 border border-rose-200", children: "\u00E2\u0153\u2022 Ch\u00C6\u00B0a \u00C4\u2018\u00C3\u00BAng" })) : (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600", children: "Ch\u00C6\u00B0a tr\u00E1\u00BA\u00A3 l\u00E1\u00BB\u009Di" })) }))] }), question.audioUrl && (_jsx("div", { className: "w-full my-1", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), question.imageUrl && (_jsx("div", { className: "flex items-center justify-center rounded-xl bg-slate-50/80 p-3 border border-slate-100 max-w-md mx-auto w-full", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: `Question ${question.questionNumber} illustration`, className: "max-h-[200px] sm:max-h-[220px] w-auto rounded-lg object-contain drop-shadow-xs" }) })), _jsxs("div", { className: "text-base sm:text-lg leading-relaxed text-slate-800 pt-1", children: [question.segments.map((segment, segmentIndex) => segment.type === 'text' ? (_jsx("span", { className: "font-medium", children: renderTextWithLineBreaks(segment.value) }, `text-${segmentIndex}`)) : (_jsx(Fragment, { children: renderBlankInput(segment.index) }, `blank-${segment.index}-${segmentIndex}`))), question.verbHint && (_jsxs("span", { className: "ml-1 italic text-slate-500 font-normal", children: ["(", question.verbHint, ")"] }))] }), showHints && question.hints && question.hints.length > 0 && !isReviewMode && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100", children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: "G\u00E1\u00BB\u00A3i \u00C3\u00BD:" }), question.hints.map((hint, idx) => (_jsx("button", { type: "button", onClick: () => {
81
+ const current = toBlankValues(answers[question.id], blankCount);
82
+ const emptyIndex = current.findIndex((value) => !value.trim());
83
+ setBlankValue(emptyIndex >= 0 ? emptyIndex : 0, hint);
84
+ }, className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 shadow-2xs transition-all hover:bg-blue-600 hover:text-white hover:border-blue-600 active:scale-95", children: hint }, idx)))] })), isReviewMode && !isExampleQ && question.acceptedAnswers.some((alts) => alts.length > 0) && (_jsx("div", { className: "mt-1 rounded-lg bg-emerald-50/80 p-2.5 text-sm border border-emerald-200/80", children: _jsxs("span", { className: "font-semibold text-emerald-800", children: ["\u00C4\u0090\u00C3\u00A1p \u00C3\u00A1n \u00C4\u2018\u00C3\u00BAng:", ' ', _jsx("span", { className: "font-bold underline", children: question.acceptedAnswers.map(formatAcceptedAnswers).filter(Boolean).join(' | ') })] }) }))] }, question.id));
41
85
  }) })] })] }));
42
86
  }
@@ -2,13 +2,20 @@
2
2
  * Types for Movers Exam UI
3
3
  */
4
4
  import type { ReactNode } from 'react';
5
+ import type { FillBlankSegment } from '../../../shared/lib/utils/fill-in-blank';
6
+ export type { FillBlankSegment };
7
+ /**
8
+ * A fill-in-the-blank stem, already split into segments. Every blank in
9
+ * `segments` gets its own input, and the student answer is always one string
10
+ * per blank, index-aligned with `acceptedAnswers`.
11
+ */
5
12
  export interface FillBlankQuestion {
6
13
  id: string;
7
14
  questionNumber: number;
8
- textBefore: string;
9
- textAfter: string;
15
+ segments: FillBlankSegment[];
16
+ /** acceptedAnswers[i] = equivalent correct answers for blank i. */
17
+ acceptedAnswers: string[][];
10
18
  verbHint?: string;
11
- correctAnswer?: string;
12
19
  imageUrl?: string;
13
20
  audioUrl?: string;
14
21
  hints?: string[];
@@ -25,8 +32,8 @@ export interface MoversListenAndWriteRendererProps {
25
32
  exampleText?: string;
26
33
  exampleHighlight?: string;
27
34
  questions: FillBlankQuestion[];
28
- answers: Record<string, string>;
29
- onAnswerChange: (questionId: string, value: string) => void;
35
+ answers: Record<string, unknown>;
36
+ onAnswerChange: (questionId: string, value: string[]) => void;
30
37
  isReviewMode?: boolean;
31
38
  showHints?: boolean;
32
39
  }
@@ -216,7 +216,7 @@ function wrapAnswer(answer, questionType) {
216
216
  // For array answers (word fill with word bank), wrap in { answers: array }
217
217
  // WORD_FILL_PARAGRAPH stores answers as array of strings
218
218
  if (Array.isArray(answer)) {
219
- if (questionType === 'WORD_FILL_PARAGRAPH') {
219
+ if (questionType === 'WORD_FILL_PARAGRAPH' || questionType === 'FILL_IN_BLANK') {
220
220
  return { answers: answer };
221
221
  }
222
222
  // Fallback for other question types with array answers
@@ -161,6 +161,7 @@ export declare function transformChooseTheCorrectAnswerGroup(questions: ApiQuest
161
161
  questions: ChooseBestAnswerQuestion[];
162
162
  example?: import('../types').ChooseBestAnswerExample;
163
163
  instruction: string;
164
+ audioUrl?: string;
164
165
  };
165
166
  export interface ChooseAdjectiveOption {
166
167
  id: string;
@@ -4,6 +4,7 @@
4
4
  * Maps backend question types to frontend renderer props.
5
5
  * Each transformer handles specific question type data structure.
6
6
  */
7
+ import { countBlanks, normalizeAcceptedAnswers, parseFillBlankStem, } from '../../../../shared/lib/utils/fill-in-blank';
7
8
  /**
8
9
  * Group questions by part_id AND questionGroup.id
9
10
  * This ensures questions with different passages (different questionGroups)
@@ -154,55 +155,20 @@ export function transformFillInBlank(questions) {
154
155
  const content = firstQuestion?.content;
155
156
  const groupPayload = firstQuestion?.questionGroup?.payload;
156
157
  const transformed = [];
157
- /** Take UI shows plain textBefore/textAfter — strip `**` so markers never leak. */
158
- const stripStemBoldMarkers = (s) => s.replace(/\*\*/g, '');
159
158
  for (const q of questions) {
160
159
  const qContent = q.content;
161
- const isExample = q.is_example || false;
162
160
  // Support both: content.question (new) and content.blanks[0].text (legacy)
163
161
  const text = qContent.question || qContent.blanks?.[0]?.text || '';
164
- // Check for multiple placeholders: {0}, {1}, etc.
165
- const placeholderMatches = [...text.matchAll(/\{(\d+)\}/g)];
166
- if (placeholderMatches.length > 1) {
167
- // Multi-placeholder: split into separate lines by newline or by placeholder
168
- const lines = text.split('\n').filter((l) => l.trim());
169
- for (const line of lines) {
170
- const lineMatch = line.match(/\{(\d+)\}/);
171
- if (!lineMatch)
172
- continue;
173
- const blankIndex = parseInt(lineMatch[1], 10);
174
- const parts = line.split(/\{\d+\}/);
175
- const textBefore = stripStemBoldMarkers(parts[0]?.trim() || '');
176
- const textAfter = stripStemBoldMarkers(parts[1]?.trim() || '');
177
- transformed.push({
178
- id: `${q.id}-blank-${blankIndex}`,
179
- questionNumber: q.question_number,
180
- textBefore,
181
- textAfter,
182
- imageUrl: qContent.imageUrl,
183
- hints: qContent.hints,
184
- isExample,
185
- correctAnswer: isExample ? q.correct_answer?.answers?.[blankIndex] : undefined,
186
- });
187
- }
188
- }
189
- else {
190
- // Single placeholder or no placeholder.
191
- // Use [\s\S] so stems with newlines still match (`.` does not cross `\n`).
192
- const match = text.match(/^([\s\S]*?)\{0\}([\s\S]*)$/);
193
- const textBefore = stripStemBoldMarkers(match ? match[1].trim() : text);
194
- const textAfter = stripStemBoldMarkers(match ? match[2].trim() : '');
195
- transformed.push({
196
- id: q.id,
197
- questionNumber: q.question_number,
198
- textBefore,
199
- textAfter,
200
- imageUrl: qContent.imageUrl,
201
- hints: qContent.hints,
202
- isExample,
203
- correctAnswer: isExample ? q.correct_answer?.answers?.[0] : undefined,
204
- });
205
- }
162
+ const segments = parseFillBlankStem(text);
163
+ transformed.push({
164
+ id: q.id,
165
+ questionNumber: q.question_number,
166
+ segments,
167
+ acceptedAnswers: normalizeAcceptedAnswers(q.correct_answer?.answers, countBlanks(segments)),
168
+ imageUrl: qContent.imageUrl,
169
+ hints: qContent.hints,
170
+ isExample: q.is_example || false,
171
+ });
206
172
  }
207
173
  return {
208
174
  questions: transformed,
@@ -228,17 +194,17 @@ export function transformWriteCorrectVerbForm(questions) {
228
194
  const content = firstQuestion?.content;
229
195
  const transformed = questions.map((q) => {
230
196
  const qContent = q.content;
231
- const sentence = qContent.sentence || '';
232
- // Parse "John {0} his homework." -> textBefore: "John", textAfter: "his homework."
233
- // [\s\S] allows instruction text with newlines before the blank.
234
- const match = sentence.match(/^([\s\S]*?)\{0\}([\s\S]*)$/);
235
- const textBefore = match ? match[1].trim() : sentence;
236
- const textAfter = match ? match[2].trim() : '';
197
+ const segments = parseFillBlankStem(qContent.sentence || '');
198
+ // Stored as { correctAnswer: "doesn't like" }; older rows used { answers: { 'blank-1': } }.
199
+ const correct = q.correct_answer;
200
+ const rawAccepted = Array.isArray(correct?.answers)
201
+ ? correct.answers
202
+ : (correct?.answers?.['blank-1'] ?? correct?.correctAnswer ?? correct?.answer);
237
203
  return {
238
204
  id: q.id,
239
205
  questionNumber: q.question_number,
240
- textBefore,
241
- textAfter,
206
+ segments,
207
+ acceptedAnswers: normalizeAcceptedAnswers(rawAccepted, countBlanks(segments)),
242
208
  verbHint: qContent.verbHint, // Pass verbHint per question
243
209
  };
244
210
  });
@@ -445,12 +411,18 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
445
411
  }
446
412
  return urls.length === 1 ? urls[0] : urls;
447
413
  };
414
+ const toAudioUrl = (value) => {
415
+ const trimmed = typeof value === 'string' ? value.trim() : '';
416
+ return trimmed !== '' ? trimmed : undefined;
417
+ };
418
+ const groupAudioUrl = toAudioUrl(content?.meta?.audioUrl);
448
419
  let example;
449
420
  if (exampleItem) {
450
421
  const exampleIndex = items.indexOf(exampleItem);
451
422
  const exOptions = mapOptions(exampleItem.content?.options, `${parent.id}-ex`);
452
423
  const exCorrect = exampleItem.correctAnswer?.answer || parentCorrect[exampleIndex] || exOptions[0]?.id || '';
453
424
  const exampleImageUrl = toQuestionImageUrl(exampleItem.content?.imageUrl);
425
+ const exampleAudioUrl = !groupAudioUrl ? toAudioUrl(exampleItem.content?.audioUrl) : undefined;
454
426
  example = {
455
427
  questionNumber: exampleItem.questionNumber || exampleIndex + 1,
456
428
  questionText: exampleItem.content?.question || '',
@@ -458,6 +430,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
458
430
  correctOptionId: exCorrect,
459
431
  optionType: (exampleItem.content?.optionType || 'text'),
460
432
  ...(exampleImageUrl !== undefined ? { imageUrl: exampleImageUrl } : {}),
433
+ ...(exampleAudioUrl ? { audioUrl: exampleAudioUrl } : {}),
461
434
  };
462
435
  }
463
436
  const transformed = regularItems.map((item) => {
@@ -465,6 +438,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
465
438
  const options = mapOptions(item.content?.options, `${parent.id}-item-${itemIndex}`);
466
439
  const correctId = item.correctAnswer?.answer || parentCorrect[itemIndex] || '';
467
440
  const itemImageUrl = toQuestionImageUrl(item.content?.imageUrl);
441
+ const itemAudioUrl = !groupAudioUrl ? toAudioUrl(item.content?.audioUrl) : undefined;
468
442
  return {
469
443
  id: `${parent.id}-item-${itemIndex}`,
470
444
  questionNumber: item.questionNumber || itemIndex + 1,
@@ -474,12 +448,14 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
474
448
  correctOptionIds: correctId ? [correctId] : [],
475
449
  optionType: (item.content?.optionType || 'text'),
476
450
  ...(itemImageUrl !== undefined ? { imageUrl: itemImageUrl } : {}),
451
+ ...(itemAudioUrl ? { audioUrl: itemAudioUrl } : {}),
477
452
  };
478
453
  });
479
454
  return {
480
455
  questions: transformed,
481
456
  example,
482
457
  instruction: content?.meta?.instruction || 'Choose the correct answer.',
458
+ ...(groupAudioUrl ? { audioUrl: groupAudioUrl } : {}),
483
459
  };
484
460
  }
485
461
  export function transformChooseCorrectAdjective(questions) {
@@ -18,10 +18,12 @@ export interface ChooseTheCorrectAnswerGroupItemData {
18
18
  points: number;
19
19
  questionNumber: number;
20
20
  imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
21
+ audioUrl?: string;
21
22
  }
22
23
  export interface ChooseTheCorrectAnswerGroupData {
23
24
  instruction: string;
24
25
  passage?: string;
26
+ audioUrl?: string;
25
27
  items: ChooseTheCorrectAnswerGroupItemData[];
26
28
  explanation?: string;
27
29
  points?: number;
@@ -3,20 +3,32 @@ export type { ValidationRef };
3
3
  export interface BlankItem {
4
4
  id: string;
5
5
  text: string;
6
+ /** @deprecated Prefer correctAnswers — kept for legacy load */
6
7
  correctAnswer: string;
8
+ /** Equivalent correct answers for this blank */
9
+ correctAnswers?: string[];
7
10
  }
8
11
  export interface FillInBlankGroup {
9
12
  number: number;
10
13
  questionCount: number;
11
14
  }
15
+ /**
16
+ * Main data interface - supports multiple blanks in a single question
17
+ * Question format: "He {0} 10 years old and she {1} 12."
18
+ * Answers format (canonical): [["is", "'s"], ["are"]]
19
+ * - answers[i] = list of equivalent correct answers for placeholder {i}
20
+ * Legacy format still accepted when loading: ["is", "are"] → [["is"], ["are"]]
21
+ */
12
22
  export interface FillInBlankData {
13
23
  question: string;
14
- answers: string[];
24
+ /** Per-blank list of equivalent correct answers */
25
+ answers: string[][];
15
26
  caseSensitive?: boolean;
16
27
  explanation?: string;
17
28
  points?: number;
18
29
  isExample?: boolean;
19
30
  imageUrl?: string;
31
+ audioUrl?: string;
20
32
  showHints?: boolean;
21
33
  hints?: string[];
22
34
  isGroup?: boolean;
@@ -41,9 +53,12 @@ export interface FillInBlankCreatorProps {
41
53
  groups?: FillInBlankGroup[];
42
54
  questionIndexInPart?: number;
43
55
  partId?: string;
56
+ /** Template questionConfig — controls which fields are shown (e.g. "audio") */
57
+ questionConfig?: string[];
44
58
  }
45
59
  export interface FillInBlankClientProps {
46
60
  questionData: FillInBlankData;
61
+ questionConfig?: string[];
47
62
  onSubmit?: (answers: string[]) => void;
48
63
  isReviewMode?: boolean;
49
64
  userAnswer?: string[];
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, Check, CircleHelp, Lightbulb, Star } from 'lucide-react';
3
+ import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2 } from 'lucide-react';
4
4
  import { cn } from '../../../../shared/lib/utils';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
@@ -13,6 +13,15 @@ function ClientImageItem({ url, alt }) {
13
13
  }
14
14
  return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
15
15
  }
16
+ function ClientAudio({ url }) {
17
+ const { previewUrl, isLoading } = usePresignedFileUrl(url);
18
+ const src = previewUrl ||
19
+ (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
20
+ if (!src && !isLoading) {
21
+ return null;
22
+ }
23
+ return (_jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-indigo-600" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-indigo-100" })) : (_jsx("audio", { controls: true, className: "h-9 w-full", src: src, preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
24
+ }
16
25
  const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
17
26
  export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
18
27
  const items = questionData.items || [];
@@ -25,10 +34,11 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
25
34
  next[itemIndex] = optionId;
26
35
  onAnswerChange(next);
27
36
  };
28
- return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
37
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && _jsx(ClientAudio, { url: questionData.audioUrl }), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
29
38
  const selectedId = userAnswers[itemIndex];
30
39
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
31
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
40
+ const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
41
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), showItemAudio && item.audioUrl && _jsx(ClientAudio, { url: item.audioUrl }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
32
42
  const isCorrect = option.id === item.correctAnswer;
33
43
  const isSelected = option.id === selectedId;
34
44
  const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
@@ -8,6 +8,7 @@ import { Textarea } from '../../../../components/ui/textarea';
8
8
  import { PointsInput } from '../../../../components/ui/points-input';
9
9
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
10
  import { Switch } from '../../../../components/ui/switch';
11
+ import { FileUpload } from '../../../../components/ui/file-upload';
11
12
  import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
12
13
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
13
14
  import { Eye, ImageIcon, Pencil, Plus, Star, Trash2, Type } from 'lucide-react';
@@ -51,6 +52,7 @@ function normalizeItems(rawItems) {
51
52
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
52
53
  questionNumber: item.questionNumber || index + 1,
53
54
  ...(imageUrl !== undefined ? { imageUrl } : {}),
55
+ ...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
54
56
  };
55
57
  });
56
58
  }
@@ -67,6 +69,7 @@ function parsePointsValue(raw, allowZero) {
67
69
  export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
68
70
  const [instruction, setInstruction] = useState(initialData?.instruction || 'Choose the correct answer.');
69
71
  const [passage, setPassage] = useState(initialData?.passage || '');
72
+ const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
70
73
  const [items, setItems] = useState(() => normalizeItems(initialData?.items));
71
74
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
72
75
  const [isClientMode, setIsClientMode] = useState(false);
@@ -77,9 +80,10 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
77
80
  onChangeRef.current = onChange;
78
81
  }, [onChange]);
79
82
  const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
80
- const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
83
+ const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl) => ({
81
84
  instruction: nextInstruction,
82
85
  ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
86
+ ...(nextAudioUrl.trim() ? { audioUrl: nextAudioUrl.trim() } : {}),
83
87
  items: nextItems,
84
88
  explanation: nextExplanation,
85
89
  points: sumPoints(nextItems),
@@ -96,7 +100,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
96
100
  previousPayloadRef.current = serialized;
97
101
  onUnsavedChangesChange?.(true);
98
102
  debouncedOnChange(payload);
99
- }, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
103
+ }, [instruction, passage, audioUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
100
104
  const validate = () => {
101
105
  const nextErrors = [];
102
106
  if (!instruction.trim()) {
@@ -161,7 +165,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
161
165
  return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EC9nh s\u1EEDa"] }) }), _jsx(ChooseTheCorrectAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
162
166
  }
163
167
  const allErrors = [...errors, ...(externalErrors || [])];
164
- return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
168
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsx("div", { className: "space-y-2", children: _jsx(FileUpload, { id: "choose-answer-group-audio", label: "Audio chung (tu\u1EF3 ch\u1ECDn)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
165
169
  isExample: checked,
166
170
  points: checked ? 0 : (item.points || 1),
167
171
  }) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "A place for someone to stay or live is called" })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { className: "flex items-center gap-2", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-teal-500" }), "H\u00ECnh \u1EA3nh (tu\u1EF3 ch\u1ECDn)"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
@@ -8,6 +8,13 @@ function asRecord(value) {
8
8
  function asString(value, fallback = '') {
9
9
  return typeof value === 'string' ? value : fallback;
10
10
  }
11
+ function asOptionalUrl(value) {
12
+ if (typeof value !== 'string') {
13
+ return undefined;
14
+ }
15
+ const trimmed = value.trim();
16
+ return trimmed !== '' ? trimmed : undefined;
17
+ }
11
18
  export function createEmptyChooseAnswerGroupItem(questionNumber) {
12
19
  return {
13
20
  question: '',
@@ -51,6 +58,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
51
58
  ];
52
59
  const isExample = record.isExample === true;
53
60
  const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
61
+ const audioUrl = asOptionalUrl(content.audioUrl);
54
62
  return {
55
63
  question: asString(content.question) || asString(record.question),
56
64
  optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
@@ -60,6 +68,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
60
68
  points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
61
69
  questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
62
70
  ...(imageUrl !== undefined ? { imageUrl } : {}),
71
+ ...(audioUrl ? { audioUrl } : {}),
63
72
  };
64
73
  }
65
74
  export function mapQuestionToChooseAnswerGroupData(question) {
@@ -73,18 +82,22 @@ export function mapQuestionToChooseAnswerGroupData(question) {
73
82
  : [];
74
83
  if (Array.isArray(content.items)) {
75
84
  const items = content.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index]));
85
+ const audioUrl = asOptionalUrl(asRecord(content.meta).audioUrl);
76
86
  return {
77
87
  instruction: asString(asRecord(content.meta).instruction) || 'Choose the correct answer.',
78
88
  passage: asString(asRecord(content.meta).passage),
89
+ ...(audioUrl ? { audioUrl } : {}),
79
90
  items,
80
91
  explanation,
81
92
  points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
82
93
  };
83
94
  }
84
95
  if (Array.isArray(answer.items)) {
96
+ const audioUrl = asOptionalUrl(answer.audioUrl);
85
97
  return {
86
98
  instruction: asString(answer.instruction, 'Choose the correct answer.'),
87
99
  passage: asString(answer.passage),
100
+ ...(audioUrl ? { audioUrl } : {}),
88
101
  items: answer.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index])),
89
102
  explanation,
90
103
  points: typeof answer.points === 'number' ? answer.points : undefined,