@tinyweb_dev/oe-exam-sdk 0.2.5 → 0.2.7

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 (91) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +10 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +22 -0
  4. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +60 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  6. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  7. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.d.ts +13 -0
  8. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +31 -0
  9. package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
  10. package/dist/components/exams/take/components/question-renderers/index.js +2 -0
  11. package/dist/components/exams/take/types.d.ts +12 -5
  12. package/dist/components/exams/take/utils/answer-transformers.js +17 -1
  13. package/dist/components/exams/take/utils/question-transformers.d.ts +45 -1
  14. package/dist/components/exams/take/utils/question-transformers.js +109 -53
  15. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  16. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +50 -0
  17. package/dist/components/questions/_shared/types/fill-in-blank-group.type.js +2 -0
  18. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  19. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  20. package/dist/components/questions/_shared/types/index.js +2 -0
  21. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +40 -0
  22. package/dist/components/questions/_shared/types/matching-with-lines-group.type.js +1 -0
  23. package/dist/components/questions/components/QuestionSearchDropdown.js +4 -0
  24. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  25. package/dist/components/questions/creator/question-type-registry.js +4 -0
  26. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  27. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  28. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  29. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  30. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  31. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  32. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  33. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.d.ts +2 -0
  34. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +64 -0
  35. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.d.ts +2 -0
  36. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +158 -0
  37. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.d.ts +7 -0
  38. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +30 -0
  39. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +20 -0
  40. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +99 -0
  41. package/dist/components/questions/types/fill-in-blank-group/index.d.ts +5 -0
  42. package/dist/components/questions/types/fill-in-blank-group/index.js +5 -0
  43. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.d.ts +4 -0
  44. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +102 -0
  45. package/dist/components/questions/types/fill-in-blank-group/register.d.ts +3 -0
  46. package/dist/components/questions/types/fill-in-blank-group/register.js +37 -0
  47. package/dist/components/questions/types/fill-in-blank-group/transform.d.ts +2 -0
  48. package/dist/components/questions/types/fill-in-blank-group/transform.js +68 -0
  49. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +20 -0
  50. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +109 -0
  51. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts +2 -0
  52. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +30 -0
  53. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts +2 -0
  54. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +180 -0
  55. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +6 -0
  56. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +26 -0
  57. package/dist/components/questions/types/matching-with-lines-group/index.d.ts +4 -0
  58. package/dist/components/questions/types/matching-with-lines-group/index.js +4 -0
  59. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.d.ts +8 -0
  60. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +104 -0
  61. package/dist/components/questions/types/matching-with-lines-group/register.d.ts +3 -0
  62. package/dist/components/questions/types/matching-with-lines-group/register.js +37 -0
  63. package/dist/components/questions/types/matching-with-lines-group/transform.d.ts +2 -0
  64. package/dist/components/questions/types/matching-with-lines-group/transform.js +44 -0
  65. package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +10 -0
  66. package/dist/components/questions/viewer/FillInBlankGroupViewer.js +14 -0
  67. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.d.ts +7 -0
  68. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.js +48 -0
  69. package/dist/components/questions/viewer/QuestionViewer.js +6 -0
  70. package/dist/components/questions/viewer/index.d.ts +2 -0
  71. package/dist/components/questions/viewer/index.js +2 -0
  72. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  73. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  74. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  75. package/dist/components/results/renderers/review-question-body-movers.js +37 -2
  76. package/dist/components/results/review-data.js +37 -0
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.js +1 -1
  79. package/dist/shared/constants/question-skills.js +4 -0
  80. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  81. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  82. package/dist/shared/lib/utils/question-reverse-transform.js +150 -3
  83. package/dist/shared/lib/utils/question-transform.js +4 -0
  84. package/dist/shared/types/common.types.d.ts +1 -1
  85. package/dist/shared/types/questions/fill-in-blank-group.d.ts +45 -0
  86. package/dist/shared/types/questions/fill-in-blank-group.js +1 -0
  87. package/dist/shared/types/questions/index.d.ts +2 -0
  88. package/dist/shared/types/questions/index.js +4 -0
  89. package/dist/shared/types/questions/matching-with-lines-group.d.ts +39 -0
  90. package/dist/shared/types/questions/matching-with-lines-group.js +1 -0
  91. package/package.json +1 -1
@@ -631,6 +631,12 @@ function ExamTakingPageContainerInner({ roomId, api, onNavigate, resultPath = (a
631
631
  map.set(`${question.id}-item-${index}`, question.id);
632
632
  });
633
633
  }
634
+ if (part.questionType === 'FILL_IN_BLANK_GROUP' &&
635
+ Array.isArray(content?.items)) {
636
+ content.items.forEach((_, index) => {
637
+ map.set(`${question.id}-item-${index}`, question.id);
638
+ });
639
+ }
634
640
  });
635
641
  });
636
642
  return map;
@@ -8,9 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * 2. Transforms data to renderer-specific props
9
9
  * 3. Renders the correct component for each question type
10
10
  */
11
- import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, MoversCrosswordPuzzleRenderer, FindWordsInMatrixRenderer, } from './question-renderers';
11
+ import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrosswordPuzzleRenderer, FindWordsInMatrixRenderer, } from './question-renderers';
12
12
  import { SpeakingQuestionRenderer } from './speaking/renderers';
13
- import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, } from '../utils/question-transformers';
13
+ import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformMatchingWithLinesGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, } from '../utils/question-transformers';
14
14
  import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
15
15
  import { normalizeColoringAssignments } from '../utils/coloring-assignments';
16
16
  export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }) {
@@ -161,6 +161,10 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
161
161
  const data = transformTrueFalseCorrectGroup(questions);
162
162
  return (_jsx(MoversTrueFalseCorrectGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
163
163
  }
164
+ case 'FILL_IN_BLANK_GROUP': {
165
+ const data = transformFillInBlankGroup(questions);
166
+ return (_jsx(MoversFillInBlankGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: data.questions.filter((question) => !question.isExample).length, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, imageUrl: data.imageUrl, showHints: data.showHints, hints: data.hints, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
167
+ }
164
168
  case 'CROSS_OUT_WORD_GROUP': {
165
169
  const data = transformCrossOutWordGroup(questions);
166
170
  return (_jsx(MoversCrossOutWordGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
@@ -169,6 +173,10 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
169
173
  const data = transformWordOrderAndMatchGroup(questions);
170
174
  return (_jsx(MoversWordOrderAndMatchGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, matchOptions: data.matchOptions, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
171
175
  }
176
+ case 'MATCHING_WITH_LINES_GROUP': {
177
+ const data = transformMatchingWithLinesGroup(questions);
178
+ return (_jsx(MoversMatchingWithLinesGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, data: data, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
179
+ }
172
180
  case 'TRUE_FALSE': {
173
181
  const data = transformTrueFalse(questions);
174
182
  return (_jsx(MoversTrueFalseRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, imageUrl: data.imageUrl, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * FILL_IN_BLANK_GROUP Renderer
3
+ * Shared audio/image on top, then each item: picture + fill blanks.
4
+ */
5
+ import type { FillInBlankGroupQuestion, FillInBlankGroupItemAnswer, FillInBlankGroupStudentItemAnswer } from '../../utils/question-transformers';
6
+ interface MoversFillInBlankGroupRendererProps {
7
+ partNumber: number;
8
+ partName?: string;
9
+ questionCount: number;
10
+ instruction: string;
11
+ audioUrl?: string;
12
+ imageUrl?: string;
13
+ showHints?: boolean;
14
+ hints?: string[];
15
+ questions: FillInBlankGroupQuestion[];
16
+ answers: Record<string, FillInBlankGroupStudentItemAnswer | undefined>;
17
+ onAnswerChange: (questionId: string, value: FillInBlankGroupStudentItemAnswer) => void;
18
+ isReviewMode?: boolean;
19
+ correctAnswerMap?: Record<string, FillInBlankGroupItemAnswer>;
20
+ }
21
+ export declare function MoversFillInBlankGroupRenderer({ partNumber, partName, questionCount, instruction, audioUrl, imageUrl, showHints, hints, questions, answers, onAnswerChange, isReviewMode, correctAnswerMap, }: MoversFillInBlankGroupRendererProps): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * FILL_IN_BLANK_GROUP Renderer
4
+ * Shared audio/image on top, then each item: picture + fill blanks.
5
+ */
6
+ import { Fragment } from 'react';
7
+ import { ResolvedImage } from '../../../../../components/common/ResolvedImage';
8
+ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
9
+ import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
10
+ import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
11
+ import { extractPlaceholderIndices, isBlankMatch, padStudentAnswers, splitStem, } from '../../../../questions/types/fill-in-blank-group/blank-utils';
12
+ function emptyAnswer(blankCount) {
13
+ return { answers: Array.from({ length: blankCount }, () => '') };
14
+ }
15
+ function ItemCard({ question, answer, onAnswerChange, isReviewMode, correctAnswer, }) {
16
+ const isDisabled = isReviewMode || question.isExample;
17
+ const current = question.isExample
18
+ ? { answers: question.exampleAnswers || emptyAnswer(question.blankCount).answers }
19
+ : { answers: padStudentAnswers(answer, question.blankCount) };
20
+ const stem = splitStem(question.question || '{0}');
21
+ const expected = correctAnswer?.answers || [];
22
+ const blankIndices = extractPlaceholderIndices(question.question || '{0}');
23
+ const allCorrect = isReviewMode
24
+ && !question.isExample
25
+ && blankIndices.length > 0
26
+ && blankIndices.every((index) => isBlankMatch(current.answers[index], expected[index]));
27
+ const anyFilled = current.answers.some((value) => value.trim().length > 0);
28
+ const isWrong = isReviewMode && !question.isExample && anyFilled && !allCorrect;
29
+ const setBlank = (blankIndex, value) => {
30
+ const next = [...current.answers];
31
+ next[blankIndex] = value;
32
+ onAnswerChange(question.id, { answers: next });
33
+ };
34
+ return (_jsxs("div", { id: `question-${question.id}`, className: `rounded-2xl border p-3 sm:p-4 transition-all ${question.isExample
35
+ ? 'border-amber-200 bg-amber-50/60'
36
+ : allCorrect
37
+ ? 'border-emerald-300 bg-emerald-50/30'
38
+ : isWrong
39
+ ? 'border-rose-300 bg-rose-50/30'
40
+ : 'border-slate-200 bg-white'}`, children: [_jsxs("div", { className: "mb-2 flex items-center gap-2", children: [_jsx("span", { className: `flex h-7 w-7 items-center justify-center rounded-full text-xs font-black text-white ${question.isExample ? 'bg-amber-500' : allCorrect ? 'bg-emerald-500' : isWrong ? 'bg-rose-500' : 'bg-blue-600'}`, children: question.questionNumber }), question.isExample && (_jsx("span", { className: "rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-extrabold uppercase text-amber-800", children: "Example" }))] }), question.audioUrl && (_jsx("div", { className: "mb-2", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), _jsxs("div", { className: "flex items-center gap-3", children: [question.imageUrl && (_jsx(ResolvedImage, { src: question.imageUrl, alt: `Question ${question.questionNumber}`, className: "h-24 w-24 flex-shrink-0 rounded-lg object-contain" })), _jsx("div", { className: "min-w-0 flex-1 text-[15px] leading-relaxed text-slate-800", children: stem.map((part, partIndex) => {
41
+ if (part.type === 'text') {
42
+ const lines = part.value.split('\n');
43
+ return (_jsx(Fragment, { children: lines.map((line, lineIndex) => (_jsxs(Fragment, { children: [lineIndex > 0 ? _jsx("br", {}) : null, line] }, lineIndex))) }, partIndex));
44
+ }
45
+ const student = current.answers[part.index] || '';
46
+ const alts = expected[part.index] || [];
47
+ const blankCorrect = isReviewMode && !question.isExample && isBlankMatch(student, alts);
48
+ const blankWrong = isReviewMode && !question.isExample && student.trim().length > 0 && !blankCorrect;
49
+ return (_jsx("input", { type: "text", value: student, disabled: isDisabled, onChange: (event) => setBlank(part.index, event.target.value), placeholder: question.isExample ? '' : '...', className: `mx-1 inline-block w-[4.5rem] rounded-md border-2 px-1 py-1 text-center text-sm font-semibold outline-none sm:w-20 ${question.isExample
50
+ ? 'border-amber-300 bg-amber-100/70 text-amber-800'
51
+ : blankCorrect
52
+ ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
53
+ : blankWrong
54
+ ? 'border-rose-400 bg-rose-50 text-rose-800'
55
+ : 'border-blue-200 bg-blue-50/40 text-blue-900 focus:border-blue-500 focus:bg-white'}` }, partIndex));
56
+ }) })] }), isReviewMode && !question.isExample && expected.length > 0 && (_jsxs("div", { className: "mt-2 rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-2 text-sm text-emerald-800", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", expected.map((alts) => alts.join(' / ') || '(trống)').join(' | ')] }))] }));
57
+ }
58
+ export function MoversFillInBlankGroupRenderer({ partNumber, partName, questionCount, instruction, audioUrl, imageUrl, showHints = false, hints = [], questions, answers, onAnswerChange, isReviewMode = false, correctAnswerMap, }) {
59
+ return (_jsxs("div", { className: "relative flex h-full flex-col gap-4 overflow-y-auto custom-scrollbar sm:gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2 p-3 sm:gap-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode })] }), (audioUrl || imageUrl) && (_jsxs("div", { className: "sticky top-0 z-20 flex flex-col gap-3 border-b border-gray-100 bg-white/95 p-3 pb-2 shadow-sm backdrop-blur sm:px-6", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[180px] rounded-lg object-contain" }) }))] })), showHints && hints.length > 0 && !isReviewMode && (_jsx("div", { className: "flex flex-wrap gap-2 px-3 sm:px-6", children: hints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:px-6 lg:grid-cols-3", children: questions.map((question) => (_jsx(ItemCard, { question: question, answer: answers[question.id], onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, correctAnswer: correctAnswerMap?.[question.id] }, question.id))) })] }));
60
+ }
@@ -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
  }
@@ -0,0 +1,13 @@
1
+ import type { MatchingWithLinesGroupPartData } from '../../utils/question-transformers';
2
+ interface MoversMatchingWithLinesGroupRendererProps {
3
+ partNumber: number;
4
+ partName?: string;
5
+ questionCount: number;
6
+ instruction: string;
7
+ data: MatchingWithLinesGroupPartData;
8
+ answers: Record<string, string | undefined>;
9
+ onAnswerChange: (questionId: string, value: string) => void;
10
+ isReviewMode?: boolean;
11
+ }
12
+ export declare function MoversMatchingWithLinesGroupRenderer({ partNumber, partName, questionCount, instruction, data, answers, onAnswerChange, isReviewMode, }: MoversMatchingWithLinesGroupRendererProps): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
4
+ import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
5
+ import { MatchingWithLinesGroupClient } from '../../../../questions/types/matching-with-lines-group/MatchingWithLinesGroupClient';
6
+ export function MoversMatchingWithLinesGroupRenderer({ partNumber, partName, questionCount, instruction, data, answers, onAnswerChange, isReviewMode = false, }) {
7
+ const userAnswers = data.questions.map((question) => {
8
+ if (question.isExample)
9
+ return question.correctRightId;
10
+ const raw = answers[question.id];
11
+ return typeof raw === 'string' ? raw : '';
12
+ });
13
+ return (_jsxs("div", { className: "w-full space-y-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, partName: partName, questionCount: questionCount }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction }), _jsx("div", { className: "mt-4", children: _jsx(MatchingWithLinesGroupClient, { questionData: {
14
+ title: data.title,
15
+ instruction: data.instruction,
16
+ rightItems: data.rightItems,
17
+ items: data.questions.map((question) => ({
18
+ imageUrl: question.imageUrl,
19
+ correctAnswer: question.correctRightId,
20
+ isExample: question.isExample,
21
+ points: question.points,
22
+ questionNumber: question.questionNumber,
23
+ })),
24
+ explanation: data.explanation,
25
+ }, isReviewMode: isReviewMode, userAnswers: userAnswers, onAnswerChange: (index, rightId) => {
26
+ const target = data.questions[index];
27
+ if (target && !target.isExample) {
28
+ onAnswerChange(target.id, rightId);
29
+ }
30
+ } }) })] }));
31
+ }
@@ -11,6 +11,7 @@ export { MoversFillWordHintRenderer } from './MoversFillWordHintRenderer';
11
11
  export { MoversGridFillRenderer } from './MoversGridFillRenderer';
12
12
  export { MoversTrueFalseRenderer } from './MoversTrueFalseRenderer';
13
13
  export { MoversTrueFalseCorrectGroupRenderer } from './MoversTrueFalseCorrectGroupRenderer';
14
+ export { MoversFillInBlankGroupRenderer } from './MoversFillInBlankGroupRenderer';
14
15
  export { MoversCrossOutWordGroupRenderer } from './MoversCrossOutWordGroupRenderer';
15
16
  export { MoversAnswerTheQuestionRenderer } from './MoversAnswerTheQuestionRenderer';
16
17
  export { MoversWriteSentencesRenderer } from './MoversWriteSentencesRenderer';
@@ -20,6 +21,7 @@ export { MoversMatchByWritingAnswerRenderer } from './MoversMatchByWritingAnswer
20
21
  export { MoversMatchWordToPictureRenderer } from './MoversMatchWordToPictureRenderer';
21
22
  export { MoversWordFillStructuredFormRenderer } from './MoversWordFillStructuredFormRenderer';
22
23
  export { MoversWordOrderAndMatchGroupRenderer } from './MoversWordOrderAndMatchGroupRenderer';
24
+ export { MoversMatchingWithLinesGroupRenderer } from './MoversMatchingWithLinesGroupRenderer';
23
25
  export { MoversCrosswordPuzzleRenderer } from './MoversCrosswordPuzzleRenderer';
24
26
  export { FindWordsInMatrixRenderer } from './FindWordsInMatrixRenderer';
25
27
  export { ArticlesViewer } from './ArticlesViewer';
@@ -15,6 +15,7 @@ export { MoversGridFillRenderer } from './MoversGridFillRenderer';
15
15
  // Starters & Movers renderers
16
16
  export { MoversTrueFalseRenderer } from './MoversTrueFalseRenderer';
17
17
  export { MoversTrueFalseCorrectGroupRenderer } from './MoversTrueFalseCorrectGroupRenderer';
18
+ export { MoversFillInBlankGroupRenderer } from './MoversFillInBlankGroupRenderer';
18
19
  export { MoversCrossOutWordGroupRenderer } from './MoversCrossOutWordGroupRenderer';
19
20
  export { MoversAnswerTheQuestionRenderer } from './MoversAnswerTheQuestionRenderer';
20
21
  export { MoversWriteSentencesRenderer } from './MoversWriteSentencesRenderer';
@@ -24,6 +25,7 @@ export { MoversMatchByWritingAnswerRenderer } from './MoversMatchByWritingAnswer
24
25
  export { MoversMatchWordToPictureRenderer } from './MoversMatchWordToPictureRenderer';
25
26
  export { MoversWordFillStructuredFormRenderer } from './MoversWordFillStructuredFormRenderer';
26
27
  export { MoversWordOrderAndMatchGroupRenderer } from './MoversWordOrderAndMatchGroupRenderer';
28
+ export { MoversMatchingWithLinesGroupRenderer } from './MoversMatchingWithLinesGroupRenderer';
27
29
  export { MoversCrosswordPuzzleRenderer } from './MoversCrosswordPuzzleRenderer';
28
30
  export { FindWordsInMatrixRenderer } from './FindWordsInMatrixRenderer';
29
31
  export { ArticlesViewer } from './ArticlesViewer';
@@ -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
  }
@@ -88,6 +88,8 @@ function findParentQuestion(subQuestionId, parts) {
88
88
  part.questionType === 'CROSS_OUT_WORD_GROUP' ||
89
89
  part.questionType === 'TRUE_FALSE_GROUP' ||
90
90
  part.questionType === 'TRUE_FALSE_CORRECT_GROUP' ||
91
+ part.questionType === 'FILL_IN_BLANK_GROUP' ||
92
+ part.questionType === 'MATCHING_WITH_LINES_GROUP' ||
91
93
  part.questionType === 'WORD_ORDER_AND_MATCH_GROUP' ||
92
94
  part.questionType === 'SPONTANEOUS_QA_GROUP' ||
93
95
  part.questionType === 'ANSWER_THE_QUESTION_GROUP') {
@@ -214,7 +216,7 @@ function wrapAnswer(answer, questionType) {
214
216
  // For array answers (word fill with word bank), wrap in { answers: array }
215
217
  // WORD_FILL_PARAGRAPH stores answers as array of strings
216
218
  if (Array.isArray(answer)) {
217
- if (questionType === 'WORD_FILL_PARAGRAPH') {
219
+ if (questionType === 'WORD_FILL_PARAGRAPH' || questionType === 'FILL_IN_BLANK') {
218
220
  return { answers: answer };
219
221
  }
220
222
  // Fallback for other question types with array answers
@@ -381,6 +383,20 @@ export function transformAnswersForSubmission(answers, parts) {
381
383
  }
382
384
  return { isTrue: null, correction: '' };
383
385
  }
386
+ if (parentType === 'FILL_IN_BLANK_GROUP') {
387
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && Array.isArray(raw.answers)) {
388
+ return {
389
+ answers: raw.answers.map((value) => String(value ?? '')),
390
+ };
391
+ }
392
+ if (Array.isArray(raw)) {
393
+ return { answers: raw.map((value) => String(value ?? '')) };
394
+ }
395
+ if (typeof raw === 'string') {
396
+ return { answers: [raw] };
397
+ }
398
+ return { answers: [] };
399
+ }
384
400
  if (parentType === 'WORD_ORDER_AND_MATCH_GROUP') {
385
401
  if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
386
402
  const obj = raw;
@@ -5,7 +5,7 @@
5
5
  * Each transformer handles specific question type data structure.
6
6
  */
7
7
  import type { FillBlankQuestion, ChooseBestAnswerQuestion, PictureChooseSubQuestion, PictureFillBlankSubQuestion, ReadingPassageQuestion, LabelThePictureLabel, FillWordHintSubQuestion, ColoringHintItem, ColoringRegion } from '../types';
8
- export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'TRUE_FALSE_CORRECT_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'MATCH_WORD_TO_PICTURE' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'CROSSWORD_PUZZLE' | 'FIND_WORDS_IN_MATRIX' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
8
+ export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'TRUE_FALSE_CORRECT_GROUP' | 'FILL_IN_BLANK_GROUP' | 'MATCHING_WITH_LINES_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'MATCH_WORD_TO_PICTURE' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'CROSSWORD_PUZZLE' | 'FIND_WORDS_IN_MATRIX' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
9
9
  export interface ApiQuestion {
10
10
  id: string;
11
11
  content: any;
@@ -285,6 +285,31 @@ export declare function transformTrueFalseCorrectGroup(questions: ApiQuestion[])
285
285
  questions: TrueFalseCorrectGroupQuestion[];
286
286
  instruction: string;
287
287
  };
288
+ export interface FillInBlankGroupItemAnswer {
289
+ answers: string[][];
290
+ caseSensitive?: boolean;
291
+ }
292
+ export interface FillInBlankGroupStudentItemAnswer {
293
+ answers: string[];
294
+ }
295
+ export interface FillInBlankGroupQuestion {
296
+ id: string;
297
+ questionNumber: number;
298
+ question: string;
299
+ imageUrl?: string;
300
+ audioUrl?: string;
301
+ isExample: boolean;
302
+ blankCount: number;
303
+ exampleAnswers?: string[];
304
+ }
305
+ export declare function transformFillInBlankGroup(questions: ApiQuestion[]): {
306
+ questions: FillInBlankGroupQuestion[];
307
+ instruction: string;
308
+ audioUrl?: string;
309
+ imageUrl?: string;
310
+ showHints?: boolean;
311
+ hints?: string[];
312
+ };
288
313
  export interface WordOrderingQuestion {
289
314
  id: string;
290
315
  questionNumber: number;
@@ -505,3 +530,22 @@ export interface FindWordsInMatrixPartData {
505
530
  explanation?: string;
506
531
  }
507
532
  export declare function transformFindWordsInMatrix(questions: ApiQuestion[]): FindWordsInMatrixPartData;
533
+ export interface MatchingWithLinesGroupQuestionItem {
534
+ id: string;
535
+ questionNumber: number;
536
+ imageUrl: string;
537
+ correctRightId: string;
538
+ isExample: boolean;
539
+ points: number;
540
+ }
541
+ export interface MatchingWithLinesGroupPartData {
542
+ questions: MatchingWithLinesGroupQuestionItem[];
543
+ rightItems: Array<{
544
+ id: string;
545
+ text: string;
546
+ }>;
547
+ title?: string;
548
+ instruction: string;
549
+ explanation?: string;
550
+ }
551
+ export declare function transformMatchingWithLinesGroup(questions: ApiQuestion[]): MatchingWithLinesGroupPartData;