@tinyweb_dev/oe-exam-sdk 0.2.12 → 0.2.14

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 (116) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +13 -3
  3. package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.d.ts +14 -0
  4. package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.js +32 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.d.ts +1 -1
  6. package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.js +10 -2
  7. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
  8. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
  9. package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.d.ts +16 -0
  10. package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.js +23 -0
  11. package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
  12. package/dist/components/exams/take/components/question-renderers/index.js +2 -0
  13. package/dist/components/exams/take/utils/answer-transformers.js +34 -1
  14. package/dist/components/exams/take/utils/question-transformers.d.ts +43 -4
  15. package/dist/components/exams/take/utils/question-transformers.js +71 -1
  16. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  17. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +7 -0
  18. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +19 -1
  19. package/dist/components/questions/_shared/types/answer-the-question.type.d.ts +3 -1
  20. package/dist/components/questions/_shared/types/answer-the-question.type.js +19 -1
  21. package/dist/components/questions/_shared/types/choose-then-answer-group.type.d.ts +47 -0
  22. package/dist/components/questions/_shared/types/choose-then-answer-group.type.js +2 -0
  23. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
  24. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  25. package/dist/components/questions/_shared/types/index.js +2 -0
  26. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +10 -1
  27. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +19 -0
  28. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
  29. package/dist/components/questions/_shared/types/sort-words-into-categories.type.d.ts +43 -0
  30. package/dist/components/questions/_shared/types/sort-words-into-categories.type.js +1 -0
  31. package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
  32. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  33. package/dist/components/questions/creator/question-type-registry.js +4 -0
  34. package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +30 -6
  35. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.d.ts +2 -0
  36. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.js +56 -0
  37. package/dist/components/questions/types/answer-the-question/transform.js +9 -3
  38. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +47 -3
  39. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +8 -2
  40. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +21 -2
  41. package/dist/components/questions/types/answer-the-question-group/transform.js +10 -1
  42. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.d.ts +2 -0
  43. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +34 -0
  44. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.d.ts +2 -0
  45. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js +174 -0
  46. package/dist/components/questions/types/choose-then-answer-group/index.d.ts +5 -0
  47. package/dist/components/questions/types/choose-then-answer-group/index.js +5 -0
  48. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.d.ts +11 -0
  49. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.js +127 -0
  50. package/dist/components/questions/types/choose-then-answer-group/register.d.ts +3 -0
  51. package/dist/components/questions/types/choose-then-answer-group/register.js +37 -0
  52. package/dist/components/questions/types/choose-then-answer-group/transform.d.ts +2 -0
  53. package/dist/components/questions/types/choose-then-answer-group/transform.js +37 -0
  54. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
  55. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
  56. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
  57. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
  58. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
  59. package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
  60. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +41 -12
  61. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +23 -8
  62. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +11 -2
  63. package/dist/components/questions/types/match-word-to-picture-group/transform.js +25 -9
  64. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
  65. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
  66. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
  67. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
  68. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
  69. package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
  70. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts +12 -0
  71. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js +74 -0
  72. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.d.ts +2 -0
  73. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +24 -0
  74. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.d.ts +2 -0
  75. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js +171 -0
  76. package/dist/components/questions/types/sort-words-into-categories/index.d.ts +4 -0
  77. package/dist/components/questions/types/sort-words-into-categories/index.js +4 -0
  78. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.d.ts +11 -0
  79. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.js +153 -0
  80. package/dist/components/questions/types/sort-words-into-categories/register.d.ts +3 -0
  81. package/dist/components/questions/types/sort-words-into-categories/register.js +37 -0
  82. package/dist/components/questions/types/sort-words-into-categories/transform.d.ts +2 -0
  83. package/dist/components/questions/types/sort-words-into-categories/transform.js +33 -0
  84. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
  85. package/dist/components/questions/viewer/AnswerTheQuestionViewer.d.ts +9 -0
  86. package/dist/components/questions/viewer/AnswerTheQuestionViewer.js +8 -0
  87. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.d.ts +10 -0
  88. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.js +39 -0
  89. package/dist/components/questions/viewer/QuestionViewer.js +13 -2
  90. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.d.ts +10 -0
  91. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.js +27 -0
  92. package/dist/components/questions/viewer/index.d.ts +3 -0
  93. package/dist/components/questions/viewer/index.js +3 -0
  94. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +2 -1
  95. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +12 -3
  96. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
  97. package/dist/components/results/renderers/review-question-body-movers.js +24 -2
  98. package/dist/components/results/review-data.js +28 -0
  99. package/dist/index.d.ts +1 -1
  100. package/dist/index.js +1 -1
  101. package/dist/shared/constants/question-skills.js +4 -0
  102. package/dist/shared/lib/utils/question-reverse-transform.js +126 -6
  103. package/dist/shared/lib/utils/question-transform-types.d.ts +1 -1
  104. package/dist/shared/lib/utils/question-transform.js +4 -0
  105. package/dist/shared/types/common.types.d.ts +1 -1
  106. package/dist/shared/types/questions/answer-the-question-group.d.ts +3 -0
  107. package/dist/shared/types/questions/choose-then-answer-group.d.ts +46 -0
  108. package/dist/shared/types/questions/choose-then-answer-group.js +1 -0
  109. package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
  110. package/dist/shared/types/questions/index.d.ts +2 -0
  111. package/dist/shared/types/questions/index.js +3 -0
  112. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +7 -1
  113. package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
  114. package/dist/shared/types/questions/sort-words-into-categories.d.ts +37 -0
  115. package/dist/shared/types/questions/sort-words-into-categories.js +1 -0
  116. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { fromAnswerTheQuestionGroupImageUrls, toAnswerTheQuestionGroupImageUrls, } from '../../_shared/types/answer-the-question-group.type';
1
2
  const CHILD_QUESTION_TYPE = 'ANSWER_THE_QUESTION';
2
3
  const GRADING_TYPE = 'AI';
3
4
  const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
@@ -26,10 +27,12 @@ export const transformAnswerTheQuestionGroup = (question) => {
26
27
  const answerData = question.answer;
27
28
  const items = Array.isArray(answerData?.items) ? answerData.items : [];
28
29
  if (items.length === 0) {
30
+ const emptyAudioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
29
31
  return {
30
32
  apiContent: {
31
33
  meta: {
32
- instruction: DEFAULT_INSTRUCTION,
34
+ instruction: answerData?.instruction || DEFAULT_INSTRUCTION,
35
+ ...(emptyAudioUrl ? { audioUrl: emptyAudioUrl } : {}),
33
36
  gradingType: GRADING_TYPE,
34
37
  isAiGraded: true,
35
38
  },
@@ -40,11 +43,13 @@ export const transformAnswerTheQuestionGroup = (question) => {
40
43
  }
41
44
  const apiItems = items.map((item, index) => {
42
45
  const expectedAnswers = normalizeExpectedAnswers(item.expectedAnswers);
46
+ const imageUrl = fromAnswerTheQuestionGroupImageUrls(item.imageUrl);
43
47
  return {
44
48
  questionType: CHILD_QUESTION_TYPE,
45
49
  ...(item.isExample ? { isExample: true } : {}),
46
50
  content: {
47
51
  question: item.question || '',
52
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
48
53
  },
49
54
  correctAnswer: { expectedAnswers },
50
55
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -52,11 +57,15 @@ export const transformAnswerTheQuestionGroup = (question) => {
52
57
  };
53
58
  });
54
59
  const passage = answerData?.passage?.trim();
60
+ const audioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
61
+ const imageUrl = toAnswerTheQuestionGroupImageUrls(answerData?.imageUrl);
55
62
  return {
56
63
  apiContent: {
57
64
  meta: {
58
65
  instruction: answerData?.instruction || DEFAULT_INSTRUCTION,
59
66
  ...(!isContentEmpty(passage) ? { passage } : {}),
67
+ ...(audioUrl ? { audioUrl } : {}),
68
+ ...(imageUrl.length ? { imageUrl } : {}),
60
69
  gradingType: GRADING_TYPE,
61
70
  isAiGraded: true,
62
71
  },
@@ -0,0 +1,2 @@
1
+ import type { ChooseThenAnswerGroupClientProps } from '../../_shared/types/choose-then-answer-group.type';
2
+ export declare function ChooseThenAnswerGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, }: ChooseThenAnswerGroupClientProps): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Lightbulb, Star } from 'lucide-react';
4
+ import { cn } from '../../../../shared/lib/utils';
5
+ function emptyAnswer() {
6
+ return { optionId: '', text: '' };
7
+ }
8
+ export function ChooseThenAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
9
+ const canEdit = Boolean(onAnswerChange) && !isReviewMode;
10
+ const items = questionData.items || [];
11
+ return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-slate-200 bg-slate-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-slate-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-slate-800", children: questionData.instruction }))] })), items.map((item, itemIndex) => {
12
+ const stored = userAnswers[itemIndex] || emptyAnswer();
13
+ const current = item.isExample
14
+ ? { optionId: item.optionId, text: stored.text }
15
+ : stored;
16
+ const expectedId = item.optionId;
17
+ const parts = (item.question || '').split('{0}');
18
+ const setAnswer = (next) => {
19
+ if (!canEdit || item.isExample)
20
+ return;
21
+ onAnswerChange?.(itemIndex, {
22
+ optionId: next.optionId ?? current.optionId,
23
+ text: next.text ?? current.text,
24
+ });
25
+ };
26
+ return (_jsxs("div", { className: "space-y-2 rounded-xl border border-gray-200 bg-white p-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-slate-800 text-xs font-bold text-white", children: 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-[10px] font-semibold uppercase text-amber-800", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("p", { className: "flex flex-wrap items-center gap-1.5 text-[15px] leading-relaxed text-slate-800", children: [_jsx("span", { children: parts[0] || '' }), _jsx("span", { className: "inline-flex items-center gap-1", children: item.options.map((option, optionIndex) => {
27
+ const selected = current.optionId === option.id;
28
+ const showReview = isReviewMode && !item.isExample && Boolean(expectedId);
29
+ const isCorrect = showReview && option.id === expectedId;
30
+ const isWrong = showReview && selected && option.id !== expectedId;
31
+ return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [optionIndex > 0 && _jsx("span", { className: "text-slate-400", children: "|" }), _jsx("button", { type: "button", disabled: !canEdit || item.isExample, onClick: () => setAnswer({ optionId: option.id }), className: cn('rounded-md border px-2.5 py-1 text-sm transition-colors', selected && !isCorrect && !isWrong && 'border-black bg-black text-white', !selected && !isCorrect && !isWrong && 'border-gray-300 bg-white text-gray-900', isCorrect && 'border-emerald-500 bg-emerald-50 text-emerald-900', isWrong && 'border-red-500 bg-red-50 text-red-900', (!canEdit || item.isExample) && 'cursor-default'), children: option.text || option.id })] }, option.id));
32
+ }) }), _jsx("span", { children: parts.slice(1).join('{0}') })] }), _jsx("textarea", { value: current.text, disabled: !canEdit || item.isExample, onChange: (event) => setAnswer({ text: event.target.value }), placeholder: "Answer for you", rows: 3, className: "w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400 disabled:bg-slate-50" })] }, `${item.questionNumber}-${itemIndex}`));
33
+ }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
34
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChooseThenAnswerGroupCreatorProps } from '../../_shared/types/choose-then-answer-group.type';
2
+ export declare function ChooseThenAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: ChooseThenAnswerGroupCreatorProps): import("react").JSX.Element;
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { Label } from '../../../../components/ui/label';
6
+ import { Input } from '../../../../components/ui/input';
7
+ import { PointsInput } from '../../../../components/ui/points-input';
8
+ import { Textarea } from '../../../../components/ui/textarea';
9
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
+ import { Switch } from '../../../../components/ui/switch';
11
+ import { useDebouncedCallback } from '../../../../shared/lib/hooks';
12
+ import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
13
+ import { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
14
+ import { createEmptyItem, createEmptyOption, sumGradablePoints, } from './map-choose-then-answer-group-data';
15
+ import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/choose-then-answer-group.type';
16
+ function normalizeItems(raw) {
17
+ if (!Array.isArray(raw) || raw.length === 0) {
18
+ return [createEmptyItem(1)];
19
+ }
20
+ return raw.map((item, index) => {
21
+ const fallback = createEmptyItem(index + 1);
22
+ const options = Array.isArray(item.options) && item.options.length >= 2
23
+ ? item.options.map((option, optionIndex) => ({
24
+ id: option.id || `opt-${optionIndex + 1}`,
25
+ text: option.text || '',
26
+ }))
27
+ : fallback.options;
28
+ return {
29
+ question: item.question || '',
30
+ options,
31
+ optionId: item.optionId || options[0]?.id || '',
32
+ isExample: item.isExample === true,
33
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
34
+ questionNumber: item.questionNumber || index + 1,
35
+ };
36
+ });
37
+ }
38
+ export function ChooseThenAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
39
+ const [title, setTitle] = useState(initialData?.title || '');
40
+ const [instruction, setInstruction] = useState(initialData?.instruction || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION);
41
+ const [items, setItems] = useState(() => normalizeItems(initialData?.items));
42
+ const [explanation, setExplanation] = useState(initialData?.explanation || '');
43
+ const [isClientMode, setIsClientMode] = useState(false);
44
+ const [errors, setErrors] = useState([]);
45
+ const onChangeRef = useRef(onChange);
46
+ const previousPayloadRef = useRef('');
47
+ useEffect(() => {
48
+ onChangeRef.current = onChange;
49
+ }, [onChange]);
50
+ const buildPayload = (nextTitle = title, nextInstruction = instruction, nextItems = items, nextExplanation = explanation) => ({
51
+ title: nextTitle,
52
+ instruction: nextInstruction,
53
+ items: nextItems,
54
+ explanation: nextExplanation,
55
+ points: sumGradablePoints(nextItems),
56
+ });
57
+ const debouncedOnChange = useDebouncedCallback((payload) => {
58
+ onChangeRef.current?.(payload);
59
+ }, 300);
60
+ useEffect(() => {
61
+ const payload = buildPayload();
62
+ const serialized = JSON.stringify(payload);
63
+ if (serialized !== previousPayloadRef.current) {
64
+ previousPayloadRef.current = serialized;
65
+ debouncedOnChange(payload);
66
+ onUnsavedChangesChange?.(true);
67
+ }
68
+ }, [title, instruction, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
69
+ const validate = () => {
70
+ const nextErrors = [];
71
+ if (!instruction.trim()) {
72
+ nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
73
+ }
74
+ if (items.length < 1) {
75
+ nextErrors.push('Cần ít nhất 1 câu.');
76
+ }
77
+ items.forEach((item, index) => {
78
+ if (!item.question.trim()) {
79
+ nextErrors.push(`Câu ${index + 1}: nội dung không được để trống.`);
80
+ }
81
+ else if (!item.question.includes('{0}')) {
82
+ nextErrors.push(`Câu ${index + 1}: phải có chỗ chọn {0}.`);
83
+ }
84
+ if (item.options.length < 2) {
85
+ nextErrors.push(`Câu ${index + 1}: cần ít nhất 2 lựa chọn.`);
86
+ }
87
+ const ids = new Set();
88
+ item.options.forEach((option, optionIndex) => {
89
+ if (!option.id.trim())
90
+ nextErrors.push(`Câu ${index + 1} option ${optionIndex + 1}: thiếu id.`);
91
+ if (!option.text.trim())
92
+ nextErrors.push(`Câu ${index + 1} option ${optionIndex + 1}: thiếu nội dung.`);
93
+ if (option.id.trim() && ids.has(option.id.trim())) {
94
+ nextErrors.push(`Câu ${index + 1}: id "${option.id}" bị trùng.`);
95
+ }
96
+ ids.add(option.id.trim());
97
+ });
98
+ if (!item.optionId.trim() || !ids.has(item.optionId)) {
99
+ nextErrors.push(`Câu ${index + 1}: chưa chọn đáp án đúng.`);
100
+ }
101
+ });
102
+ setErrors(nextErrors);
103
+ return nextErrors.length === 0;
104
+ };
105
+ useEffect(() => {
106
+ if (!validationRef)
107
+ return;
108
+ validationRef.current = {
109
+ triggerValidation: async () => validate(),
110
+ getFormData: () => buildPayload(),
111
+ };
112
+ return () => {
113
+ validationRef.current = null;
114
+ };
115
+ });
116
+ const updateItem = (index, patch) => {
117
+ setItems((prev) => prev.map((item, i) => {
118
+ if (i !== index)
119
+ return item;
120
+ const next = { ...item, ...patch };
121
+ if (next.isExample)
122
+ next.points = 0;
123
+ return next;
124
+ }));
125
+ };
126
+ const updateOption = (itemIndex, optionIndex, text) => {
127
+ setItems((prev) => prev.map((item, i) => {
128
+ if (i !== itemIndex)
129
+ return item;
130
+ return {
131
+ ...item,
132
+ options: item.options.map((option, j) => (j === optionIndex ? { ...option, text } : option)),
133
+ };
134
+ }));
135
+ };
136
+ const addOption = (itemIndex) => {
137
+ setItems((prev) => prev.map((item, i) => {
138
+ if (i !== itemIndex)
139
+ return item;
140
+ return {
141
+ ...item,
142
+ options: [...item.options, createEmptyOption(item.options.map((option) => option.id))],
143
+ };
144
+ }));
145
+ };
146
+ const removeOption = (itemIndex, optionIndex) => {
147
+ setItems((prev) => prev.map((item, i) => {
148
+ if (i !== itemIndex || item.options.length <= 2)
149
+ return item;
150
+ const options = item.options.filter((_, j) => j !== optionIndex);
151
+ const removedId = item.options[optionIndex]?.id;
152
+ return {
153
+ ...item,
154
+ options,
155
+ optionId: item.optionId === removedId ? (options[0]?.id || '') : item.optionId,
156
+ };
157
+ }));
158
+ };
159
+ const addItem = () => {
160
+ setItems((prev) => [...prev, createEmptyItem(prev.length + 1)]);
161
+ };
162
+ const removeItem = (index) => {
163
+ if (items.length <= 1)
164
+ return;
165
+ setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({
166
+ ...item,
167
+ questionNumber: i + 1,
168
+ })));
169
+ };
170
+ const displayErrors = [...errors, ...(externalErrors || [])];
171
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((v) => !v), children: [isClientMode ? _jsx(Pencil, { className: "mr-2 h-4 w-4" }) : _jsx(Eye, { className: "mr-2 h-4 w-4" }), isClientMode ? 'Sửa' : 'Xem trước'] }) }), isClientMode ? (_jsx(ChooseThenAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "cta-title", value: title, onChange: (event) => setTitle(event.target.value), placeholder: "GRAMMAR" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "cta-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION })] })] }), items.map((item, index) => (_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 ", index + 1] }), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] }), _jsxs(CardContent, { className: "space-y-3", children: [_jsx(Input, { value: item.question, onChange: (event) => updateItem(index, { question: event.target.value }), placeholder: "Did you eat {0} for lunch today?" }), _jsxs("p", { className: "text-xs text-slate-500", children: ["D\u00F9ng ", '{0}', " cho ch\u1ED7 hi\u1EC7n chip ch\u1ECDn."] }), item.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: option.text, onChange: (event) => updateOption(index, optionIndex, event.target.value), placeholder: `Lựa chọn ${optionIndex + 1}` }), item.options.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeOption(index, optionIndex), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] }, option.id))), _jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: () => addOption(index), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm l\u1EF1a ch\u1ECDn"] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("select", { className: "h-10 rounded-md border border-input bg-background px-3 text-sm", value: item.optionId, onChange: (event) => updateItem(index, { optionId: event.target.value }), children: item.options.map((option) => (_jsx("option", { value: option.id, children: option.text || option.id }, option.id))) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Switch, { id: `cta-example-${index}`, checked: item.isExample === true, onCheckedChange: (checked) => updateItem(index, { isExample: checked }) }), _jsxs(Label, { htmlFor: `cta-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
172
+ points: Number.parseInt(event.target.value, 10) || 1,
173
+ }) }))] })] })] }, `${item.questionNumber}-${index}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm c\u00E2u"] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-explanation", children: "Gi\u1EA3i th\u00EDch" }), _jsx(Textarea, { id: "cta-explanation", value: explanation, onChange: (event) => setExplanation(event.target.value) })] })] })), displayErrors.length > 0 && (_jsx("ul", { className: "list-disc space-y-1 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: displayErrors.map((error) => (_jsx("li", { children: error }, error))) }))] }));
174
+ }
@@ -0,0 +1,5 @@
1
+ export { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
2
+ export { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
3
+ export { transformChooseThenAnswerGroup } from './transform';
4
+ export { chooseThenAnswerGroupRegistration } from './register';
5
+ export { mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, } from './map-choose-then-answer-group-data';
@@ -0,0 +1,5 @@
1
+ export { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
2
+ export { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
3
+ export { transformChooseThenAnswerGroup } from './transform';
4
+ export { chooseThenAnswerGroupRegistration } from './register';
5
+ export { mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, } from './map-choose-then-answer-group-data';
@@ -0,0 +1,11 @@
1
+ import type { ChooseThenAnswerGroupData, ChooseThenAnswerGroupItemData, ChooseThenAnswerOption, ChooseThenAnswerStudentItemAnswer } from '../../_shared/types/choose-then-answer-group.type';
2
+ export declare function nextPrefixedId(prefix: string, existing: string[]): string;
3
+ export declare function sumGradablePoints(items: ChooseThenAnswerGroupItemData[]): number;
4
+ export declare function createEmptyOption(existingIds: string[]): ChooseThenAnswerOption;
5
+ export declare function createEmptyItem(questionNumber: number): ChooseThenAnswerGroupItemData;
6
+ export declare function parseItemAnswer(raw: unknown): {
7
+ optionId: string;
8
+ };
9
+ export declare function parseStudentAnswer(raw: unknown): ChooseThenAnswerStudentItemAnswer;
10
+ export declare function mapApiItem(item: unknown, index: number, fallbackAnswer?: unknown): ChooseThenAnswerGroupItemData;
11
+ export declare function mapQuestionToChooseThenAnswerGroupData(question: Record<string, unknown> | null | undefined): ChooseThenAnswerGroupData;
@@ -0,0 +1,127 @@
1
+ import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/choose-then-answer-group.type';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ function asRecord(value) {
6
+ return isRecord(value) ? value : {};
7
+ }
8
+ function asString(value, fallback = '') {
9
+ return typeof value === 'string' ? value : fallback;
10
+ }
11
+ export function nextPrefixedId(prefix, existing) {
12
+ let n = existing.length + 1;
13
+ const used = new Set(existing);
14
+ while (used.has(`${prefix}${n}`))
15
+ n += 1;
16
+ return `${prefix}${n}`;
17
+ }
18
+ export function sumGradablePoints(items) {
19
+ return items.reduce((total, item) => {
20
+ if (item.isExample)
21
+ return total;
22
+ return total + (typeof item.points === 'number' ? item.points : 1);
23
+ }, 0);
24
+ }
25
+ export function createEmptyOption(existingIds) {
26
+ return {
27
+ id: nextPrefixedId('opt-', existingIds),
28
+ text: '',
29
+ };
30
+ }
31
+ export function createEmptyItem(questionNumber) {
32
+ const first = createEmptyOption([]);
33
+ const second = createEmptyOption([first.id]);
34
+ return {
35
+ question: '',
36
+ options: [first, second],
37
+ optionId: first.id,
38
+ isExample: false,
39
+ points: 1,
40
+ questionNumber,
41
+ };
42
+ }
43
+ export function parseItemAnswer(raw) {
44
+ if (!isRecord(raw))
45
+ return { optionId: '' };
46
+ return { optionId: asString(raw.optionId) };
47
+ }
48
+ export function parseStudentAnswer(raw) {
49
+ if (!isRecord(raw))
50
+ return { optionId: '', text: '' };
51
+ return {
52
+ optionId: asString(raw.optionId),
53
+ text: asString(raw.text),
54
+ };
55
+ }
56
+ function mapOptions(raw) {
57
+ if (!Array.isArray(raw) || raw.length < 2) {
58
+ const first = createEmptyOption([]);
59
+ const second = createEmptyOption([first.id]);
60
+ return [first, second];
61
+ }
62
+ return raw.map((item, index) => {
63
+ const record = asRecord(item);
64
+ return {
65
+ id: asString(record.id, `opt-${index + 1}`),
66
+ text: asString(record.text),
67
+ };
68
+ });
69
+ }
70
+ export function mapApiItem(item, index, fallbackAnswer) {
71
+ const record = asRecord(item);
72
+ const content = asRecord(record.content);
73
+ const correctAnswer = asRecord(record.correctAnswer);
74
+ const isExample = record.isExample === true;
75
+ const options = mapOptions(content.options);
76
+ const parsed = parseItemAnswer(correctAnswer.answer ?? fallbackAnswer);
77
+ return {
78
+ question: asString(content.question) || asString(record.question),
79
+ options,
80
+ optionId: parsed.optionId || options[0]?.id || '',
81
+ isExample,
82
+ points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
83
+ questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
84
+ };
85
+ }
86
+ export function mapQuestionToChooseThenAnswerGroupData(question) {
87
+ const source = asRecord(question);
88
+ const content = asRecord(source.content);
89
+ const answer = asRecord(source.answer);
90
+ const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
91
+ const explanation = asString(source.explanation) || asString(answer.explanation);
92
+ const meta = asRecord(content.meta ?? answer.meta);
93
+ const fallbackAnswers = Array.isArray(correctAnswer.answer) ? correctAnswer.answer : [];
94
+ if (Array.isArray(content.items)) {
95
+ const items = content.items.map((item, index) => mapApiItem(item, index, fallbackAnswers[index]));
96
+ return {
97
+ title: asString(meta.title) || asString(answer.title),
98
+ instruction: asString(meta.instruction) ||
99
+ asString(answer.instruction) ||
100
+ CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
101
+ items,
102
+ explanation,
103
+ points: sumGradablePoints(items),
104
+ };
105
+ }
106
+ if (Array.isArray(answer.items)) {
107
+ const items = answer.items.map((item, index) => ({
108
+ ...createEmptyItem(index + 1),
109
+ ...item,
110
+ questionNumber: item.questionNumber || index + 1,
111
+ }));
112
+ return {
113
+ title: asString(answer.title),
114
+ instruction: asString(answer.instruction) || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
115
+ items,
116
+ explanation,
117
+ points: typeof answer.points === 'number' ? answer.points : sumGradablePoints(items),
118
+ };
119
+ }
120
+ return {
121
+ title: '',
122
+ instruction: CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
123
+ items: [createEmptyItem(1)],
124
+ explanation,
125
+ points: 1,
126
+ };
127
+ }
@@ -0,0 +1,3 @@
1
+ import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
2
+ export { createEmptyItem, mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, sumGradablePoints, } from './map-choose-then-answer-group-data';
3
+ export declare const chooseThenAnswerGroupRegistration: QuestionTypeRegistration;
@@ -0,0 +1,37 @@
1
+ import { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
2
+ import { mapQuestionToChooseThenAnswerGroupData } from './map-choose-then-answer-group-data';
3
+ export { createEmptyItem, mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, sumGradablePoints, } from './map-choose-then-answer-group-data';
4
+ export const chooseThenAnswerGroupRegistration = {
5
+ component: ChooseThenAnswerGroupCreator,
6
+ transformInitialData: (initialData) => mapQuestionToChooseThenAnswerGroupData(initialData),
7
+ getExtraProps: (ctx) => ({
8
+ onUnsavedChangesChange: ctx.onUnsavedChangesChange,
9
+ validationRef: ctx.validationRef,
10
+ }),
11
+ wrapOnSave: (data, ctx) => {
12
+ const payload = data;
13
+ return {
14
+ type: ctx.questionType,
15
+ content: ctx.state.content,
16
+ points: payload.points ?? ctx.state.points ?? 1,
17
+ level: ctx.state.level,
18
+ difficulty: ctx.state.difficulty,
19
+ skill: ctx.state.skill,
20
+ answer: payload,
21
+ explanation: payload.explanation || '',
22
+ };
23
+ },
24
+ wrapOnChange: (data, ctx) => {
25
+ const payload = data;
26
+ return {
27
+ type: ctx.questionType,
28
+ content: ctx.state.content,
29
+ points: payload.points ?? ctx.state.points ?? 1,
30
+ level: ctx.state.level,
31
+ difficulty: ctx.state.difficulty,
32
+ skill: ctx.state.skill,
33
+ answer: payload,
34
+ explanation: payload.explanation || '',
35
+ };
36
+ },
37
+ };
@@ -0,0 +1,2 @@
1
+ import type { TransformHandler } from '../../../../shared/lib/utils/question-transform-types';
2
+ export declare const transformChooseThenAnswerGroup: TransformHandler;
@@ -0,0 +1,37 @@
1
+ import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION, CHOOSE_THEN_ANSWER_ITEM_TYPE, } from '../../_shared/types/choose-then-answer-group.type';
2
+ import { sumGradablePoints } from './map-choose-then-answer-group-data';
3
+ export const transformChooseThenAnswerGroup = (question) => {
4
+ const answerData = question.answer;
5
+ const items = Array.isArray(answerData?.items) ? answerData.items : [];
6
+ if (items.length === 0) {
7
+ return { apiContent: { items: [] }, apiCorrectAnswer: { answer: [] } };
8
+ }
9
+ const title = typeof answerData?.title === 'string' ? answerData.title.trim() : '';
10
+ const apiItems = items.map((item, index) => ({
11
+ questionType: CHOOSE_THEN_ANSWER_ITEM_TYPE,
12
+ ...(item.isExample ? { isExample: true } : {}),
13
+ content: {
14
+ question: item.question || '',
15
+ options: (item.options || []).map((option) => ({
16
+ id: option.id || '',
17
+ text: option.text || '',
18
+ })),
19
+ },
20
+ correctAnswer: { answer: { optionId: item.optionId || '' } },
21
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
22
+ questionNumber: item.questionNumber || index + 1,
23
+ }));
24
+ return {
25
+ apiContent: {
26
+ meta: {
27
+ instruction: answerData?.instruction || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
28
+ ...(title ? { title } : {}),
29
+ },
30
+ items: apiItems,
31
+ },
32
+ apiCorrectAnswer: {
33
+ answer: apiItems.map((item) => item.correctAnswer.answer),
34
+ },
35
+ totalPoints: sumGradablePoints(items),
36
+ };
37
+ };
@@ -1,15 +1,44 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Lightbulb, Star, Volume2 } from 'lucide-react';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BookOpen, Lightbulb, Star, Volume2 } from 'lucide-react';
4
4
  import { cn } from '../../../../shared/lib/utils';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
- import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, splitStem, } from './blank-utils';
6
+ import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, resolveBlankOptions, splitStem, splitStemTextDecorations, toFillInBlankImageUrls, } from './blank-utils';
7
+ const STEM_MARK_STYLE = {
8
+ fontWeight: 700,
9
+ fontSize: 20,
10
+ lineHeight: 1,
11
+ };
12
+ function StemText({ value }) {
13
+ return (_jsx(_Fragment, { children: splitStemTextDecorations(value).map((fragment, fragmentIndex) => {
14
+ if (fragment.type === 'br') {
15
+ return _jsx("br", {}, `br-${fragmentIndex}`);
16
+ }
17
+ if (fragment.type === 'tick') {
18
+ return (_jsx("span", { style: { ...STEM_MARK_STYLE, color: '#16a34a' }, children: fragment.value }, `tick-${fragmentIndex}`));
19
+ }
20
+ if (fragment.type === 'cross') {
21
+ return (_jsx("span", { style: { ...STEM_MARK_STYLE, color: '#dc2626' }, children: fragment.value }, `cross-${fragmentIndex}`));
22
+ }
23
+ return _jsx("span", { children: fragment.value }, `text-${fragmentIndex}`);
24
+ }) }));
25
+ }
26
+ function isDisplayableMediaUrl(value) {
27
+ return (value.startsWith('http://')
28
+ || value.startsWith('https://')
29
+ || value.startsWith('blob:')
30
+ || value.startsWith('data:')
31
+ || value.startsWith('/'));
32
+ }
7
33
  function MediaPreview({ src, kind, alt }) {
8
34
  const { previewUrl, isLoading } = usePresignedFileUrl(src);
9
35
  if (isLoading) {
10
36
  return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
11
37
  }
12
- const url = previewUrl || src;
38
+ const url = previewUrl || (isDisplayableMediaUrl(src) ? src : '');
39
+ if (!url) {
40
+ return null;
41
+ }
13
42
  if (kind === 'audio') {
14
43
  return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full" });
15
44
  }
@@ -35,30 +64,36 @@ export function FillInBlankGroupClient({ questionData, isReviewMode = false, use
35
64
  next[itemIndex] = { answers: current };
36
65
  onAnswerChange(next);
37
66
  };
38
- 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 && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), questionData.imageUrl && (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(MediaPreview, { src: questionData.imageUrl, kind: "image", alt: "Group image" }) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.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 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
67
+ 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 leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), toFillInBlankImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: toFillInBlankImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, kind: "image", alt: `Group image ${imageIndex + 1}` }, `group-image-${imageIndex}`))) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.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 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
39
68
  const blankCount = blankSlotCount(item.question || '{0}');
40
69
  const placeholderIndices = extractPlaceholderIndices(item.question || '{0}');
41
70
  const userValue = padStudentAnswers(userAnswers[itemIndex], blankCount);
42
71
  const stem = splitStem(item.question || '{0}');
43
72
  const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
44
- 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-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: 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: "flex items-center gap-3 p-3", children: [item.imageUrl && (_jsx(MediaPreview, { src: item.imageUrl, kind: "image", alt: `Item ${item.questionNumber}` })), _jsxs("div", { className: "min-w-0 flex-1 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
73
+ 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-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: 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-3", children: [toFillInBlankImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-2", children: toFillInBlankImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, kind: "image", alt: `Item ${item.questionNumber} image ${imageIndex + 1}` }, `${item.questionNumber}-${imageIndex}`))) })), _jsxs("div", { className: "min-w-0 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
45
74
  if (part.type === 'text') {
46
- return _jsx("span", { children: part.value }, partIndex);
75
+ return (_jsx("span", { children: _jsx(StemText, { value: part.value }) }, partIndex));
47
76
  }
48
77
  const alts = item.answers[part.index] || [];
49
78
  const student = userValue[part.index] || '';
50
79
  const canEdit = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
51
80
  const correct = isReviewMode && isBlankMatch(student, alts);
52
81
  const wrong = isReviewMode && student.trim().length > 0 && !correct;
53
- return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: cn('mx-1 inline-block w-16 rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', item.isExample
54
- ? 'border-amber-300 bg-amber-50 text-amber-800'
55
- : correct
56
- ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
57
- : wrong
58
- ? 'border-rose-400 bg-rose-50 text-rose-800'
59
- : canEdit
60
- ? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
61
- : 'border-blue-200 bg-blue-50 text-blue-900') }, partIndex));
82
+ const blankOptions = resolveBlankOptions(part.index, item.wordBank, questionData.wordBank);
83
+ const useOptions = Boolean(blankOptions);
84
+ const fieldClass = cn('mx-1 inline-block rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', useOptions ? 'min-w-[5.5rem]' : 'w-16', item.isExample
85
+ ? 'border-amber-300 bg-amber-50 text-amber-800'
86
+ : correct
87
+ ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
88
+ : wrong
89
+ ? 'border-rose-400 bg-rose-50 text-rose-800'
90
+ : canEdit
91
+ ? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
92
+ : 'border-blue-200 bg-blue-50 text-blue-900');
93
+ if (useOptions && blankOptions) {
94
+ return (_jsxs("select", { disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), className: fieldClass, children: [!item.isExample && _jsx("option", { value: "", children: "..." }), blankOptions.map((option) => (_jsx("option", { value: option, children: option }, option)))] }, partIndex));
95
+ }
96
+ return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: fieldClass }, partIndex));
62
97
  })] })] }), isReviewMode && !item.isExample && (_jsxs("div", { className: "border-t border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["\u0110\u00E1p \u00E1n: ", placeholderIndices.map((index) => joinSlashAnswers(item.answers[index]) || '(trống)').join(' | ')] }))] }, `${item.questionNumber}-${itemIndex}`));
63
98
  }) }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
64
99
  }