@tinyweb_dev/oe-exam-sdk 0.2.13 → 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 (97) 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/SortWordsIntoCategoriesRenderer.d.ts +16 -0
  8. package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.js +23 -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/utils/answer-transformers.js +34 -1
  12. package/dist/components/exams/take/utils/question-transformers.d.ts +42 -3
  13. package/dist/components/exams/take/utils/question-transformers.js +71 -1
  14. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  15. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +5 -1
  16. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +7 -0
  17. package/dist/components/questions/_shared/types/answer-the-question.type.d.ts +3 -1
  18. package/dist/components/questions/_shared/types/answer-the-question.type.js +19 -1
  19. package/dist/components/questions/_shared/types/choose-then-answer-group.type.d.ts +47 -0
  20. package/dist/components/questions/_shared/types/choose-then-answer-group.type.js +2 -0
  21. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  22. package/dist/components/questions/_shared/types/index.js +2 -0
  23. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +9 -3
  24. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +19 -0
  25. package/dist/components/questions/_shared/types/sort-words-into-categories.type.d.ts +43 -0
  26. package/dist/components/questions/_shared/types/sort-words-into-categories.type.js +1 -0
  27. package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
  28. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  29. package/dist/components/questions/creator/question-type-registry.js +4 -0
  30. package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +30 -6
  31. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.d.ts +2 -0
  32. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.js +56 -0
  33. package/dist/components/questions/types/answer-the-question/transform.js +9 -3
  34. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +42 -9
  35. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +7 -5
  36. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +18 -4
  37. package/dist/components/questions/types/answer-the-question-group/transform.js +8 -2
  38. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.d.ts +2 -0
  39. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +34 -0
  40. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.d.ts +2 -0
  41. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js +174 -0
  42. package/dist/components/questions/types/choose-then-answer-group/index.d.ts +5 -0
  43. package/dist/components/questions/types/choose-then-answer-group/index.js +5 -0
  44. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.d.ts +11 -0
  45. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.js +127 -0
  46. package/dist/components/questions/types/choose-then-answer-group/register.d.ts +3 -0
  47. package/dist/components/questions/types/choose-then-answer-group/register.js +37 -0
  48. package/dist/components/questions/types/choose-then-answer-group/transform.d.ts +2 -0
  49. package/dist/components/questions/types/choose-then-answer-group/transform.js +37 -0
  50. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +39 -10
  51. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -8
  52. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +8 -3
  53. package/dist/components/questions/types/match-word-to-picture-group/transform.js +21 -13
  54. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts +12 -0
  55. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js +74 -0
  56. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.d.ts +2 -0
  57. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +24 -0
  58. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.d.ts +2 -0
  59. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js +171 -0
  60. package/dist/components/questions/types/sort-words-into-categories/index.d.ts +4 -0
  61. package/dist/components/questions/types/sort-words-into-categories/index.js +4 -0
  62. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.d.ts +11 -0
  63. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.js +153 -0
  64. package/dist/components/questions/types/sort-words-into-categories/register.d.ts +3 -0
  65. package/dist/components/questions/types/sort-words-into-categories/register.js +37 -0
  66. package/dist/components/questions/types/sort-words-into-categories/transform.d.ts +2 -0
  67. package/dist/components/questions/types/sort-words-into-categories/transform.js +33 -0
  68. package/dist/components/questions/viewer/AnswerTheQuestionViewer.d.ts +9 -0
  69. package/dist/components/questions/viewer/AnswerTheQuestionViewer.js +8 -0
  70. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.d.ts +10 -0
  71. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.js +39 -0
  72. package/dist/components/questions/viewer/QuestionViewer.js +13 -2
  73. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.d.ts +10 -0
  74. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.js +27 -0
  75. package/dist/components/questions/viewer/index.d.ts +3 -0
  76. package/dist/components/questions/viewer/index.js +3 -0
  77. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +2 -1
  78. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +12 -3
  79. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
  80. package/dist/components/results/renderers/review-question-body-movers.js +24 -2
  81. package/dist/components/results/review-data.js +28 -0
  82. package/dist/index.d.ts +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/shared/constants/question-skills.js +4 -0
  85. package/dist/shared/lib/utils/question-reverse-transform.js +98 -2
  86. package/dist/shared/lib/utils/question-transform-types.d.ts +1 -1
  87. package/dist/shared/lib/utils/question-transform.js +4 -0
  88. package/dist/shared/types/common.types.d.ts +1 -1
  89. package/dist/shared/types/questions/answer-the-question-group.d.ts +3 -0
  90. package/dist/shared/types/questions/choose-then-answer-group.d.ts +46 -0
  91. package/dist/shared/types/questions/choose-then-answer-group.js +1 -0
  92. package/dist/shared/types/questions/index.d.ts +2 -0
  93. package/dist/shared/types/questions/index.js +3 -0
  94. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +6 -2
  95. package/dist/shared/types/questions/sort-words-into-categories.d.ts +37 -0
  96. package/dist/shared/types/questions/sort-words-into-categories.js +1 -0
  97. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import type { MutableRefObject } from 'react';
2
2
  export declare const MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION = "Look and complete.";
3
3
  export declare const MATCH_WORD_TO_PICTURE_ITEM_TYPE: "MATCH_WORD_TO_PICTURE";
4
+ export declare function toMatchWordToPictureImageUrls(value?: unknown): string[];
5
+ export declare function fromMatchWordToPictureImageUrls(value?: unknown): string | string[] | undefined;
4
6
  export interface MatchWordToPictureGroupItemData {
5
- imageUrl?: string;
7
+ imageUrl?: string | string[];
6
8
  correctAnswer: string;
7
9
  isExample?: boolean;
8
10
  points: number;
@@ -11,8 +13,12 @@ export interface MatchWordToPictureGroupItemData {
11
13
  export interface MatchWordToPictureGroupData {
12
14
  title?: string;
13
15
  instruction: string;
14
- /** Shared photo when items are numbered labels on one picture. */
15
- imageUrl?: string;
16
+ /** Shared reading passage (HTML) above the match-word-to-picture UI. */
17
+ passage?: string;
18
+ /** Shared listening audio played once under the instruction. */
19
+ audioUrl?: string;
20
+ /** Shared cover/scene photo under the instruction (can coexist with item images). */
21
+ imageUrl?: string | string[];
16
22
  wordBank: string[];
17
23
  items: MatchWordToPictureGroupItemData[];
18
24
  explanation?: string;
@@ -1,2 +1,21 @@
1
1
  export const MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION = 'Look and complete.';
2
2
  export const MATCH_WORD_TO_PICTURE_ITEM_TYPE = 'MATCH_WORD_TO_PICTURE';
3
+ export function toMatchWordToPictureImageUrls(value) {
4
+ if (Array.isArray(value)) {
5
+ return value
6
+ .filter((url) => typeof url === 'string')
7
+ .map((url) => url.trim())
8
+ .filter((url) => url !== '');
9
+ }
10
+ if (typeof value === 'string' && value.trim() !== '') {
11
+ return [value.trim()];
12
+ }
13
+ return [];
14
+ }
15
+ export function fromMatchWordToPictureImageUrls(value) {
16
+ const urls = toMatchWordToPictureImageUrls(value);
17
+ if (urls.length === 0) {
18
+ return undefined;
19
+ }
20
+ return urls.length === 1 ? urls[0] : urls;
21
+ }
@@ -0,0 +1,43 @@
1
+ import type { MutableRefObject } from 'react';
2
+ export declare const SORT_WORDS_INTO_CATEGORIES_DEFAULT_INSTRUCTION = "Read and sort the words.";
3
+ export interface SortWordsCategory {
4
+ id: string;
5
+ name: string;
6
+ }
7
+ export interface SortWordsWordItem {
8
+ id: string;
9
+ text: string;
10
+ categoryId: string;
11
+ isExample?: boolean;
12
+ points: number;
13
+ }
14
+ export interface SortWordsPlacement {
15
+ wordId: string;
16
+ categoryId: string;
17
+ }
18
+ export interface SortWordsIntoCategoriesData {
19
+ title?: string;
20
+ instruction: string;
21
+ categories: SortWordsCategory[];
22
+ words: SortWordsWordItem[];
23
+ explanation?: string;
24
+ points?: number;
25
+ }
26
+ export interface SortWordsIntoCategoriesCreatorProps {
27
+ initialData?: SortWordsIntoCategoriesData;
28
+ onSave?: (data: SortWordsIntoCategoriesData) => void;
29
+ onCancel?: () => void;
30
+ onChange?: (data: SortWordsIntoCategoriesData) => void;
31
+ externalErrors?: string[];
32
+ onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
33
+ validationRef?: MutableRefObject<{
34
+ triggerValidation: () => Promise<boolean>;
35
+ getFormData?: () => SortWordsIntoCategoriesData;
36
+ } | null>;
37
+ }
38
+ export interface SortWordsIntoCategoriesClientProps {
39
+ questionData: SortWordsIntoCategoriesData;
40
+ isReviewMode?: boolean;
41
+ userPlacements?: SortWordsPlacement[];
42
+ onPlacementsChange?: (next: SortWordsPlacement[]) => void;
43
+ }
@@ -0,0 +1 @@
1
+ export const SORT_WORDS_INTO_CATEGORIES_DEFAULT_INSTRUCTION = 'Read and sort the words.';
@@ -71,6 +71,8 @@ const getQuestionTypeName = (type) => {
71
71
  'MATCH_COLUMNS_TO_MAKE_SENTENCES': 'Ghép cột thành câu',
72
72
  'MATCH_WORD_TO_PICTURE_GROUP': 'Nối từ với tranh (nhóm)',
73
73
  'WORD_ORDERING_GROUP': 'Sắp xếp từ (nhóm)',
74
+ 'SORT_WORDS_INTO_CATEGORIES': 'Sắp xếp từ vào nhóm',
75
+ 'CHOOSE_THEN_ANSWER_GROUP': 'Chọn đáp án rồi trả lời',
74
76
  };
75
77
  return type ? typeMap[type] || type.replace(/_/g, ' ').toLowerCase() : 'Chưa xác định';
76
78
  };
@@ -31,6 +31,8 @@ export function QuestionTypeSelector({ value, onValueChange, disabled, }) {
31
31
  'MATCH_COLUMNS_TO_MAKE_SENTENCES',
32
32
  'MATCH_WORD_TO_PICTURE_GROUP',
33
33
  'WORD_ORDERING_GROUP',
34
+ 'SORT_WORDS_INTO_CATEGORIES',
35
+ 'CHOOSE_THEN_ANSWER_GROUP',
34
36
  'WRITE_SENTENCES',
35
37
  'FILL_IN_BLANK_GROUP',
36
38
  ];
@@ -14,6 +14,7 @@ import { chooseTheCorrectAnswerRegistration } from '../types/choose-the-correct-
14
14
  import { chooseTheCorrectAnswerGroupRegistration } from '../types/choose-the-correct-answer-group/register';
15
15
  import { trueFalseGroupRegistration } from '../types/true-false-group/register';
16
16
  import { trueFalseCorrectGroupRegistration } from '../types/true-false-correct-group/register';
17
+ import { chooseThenAnswerGroupRegistration } from '../types/choose-then-answer-group/register';
17
18
  import { fillInBlankGroupRegistration } from '../types/fill-in-blank-group/register';
18
19
  import { crossOutWordGroupRegistration } from '../types/cross-out-word-group/register';
19
20
  import { writeAShortLetterRegistration } from '../types/write-a-short-letter/register';
@@ -49,6 +50,7 @@ import { wordFillStructuredFormRegistration } from '../types/word-fill-structure
49
50
  import { wordOrderAndMatchGroupRegistration } from '../types/word-order-and-match-group/register';
50
51
  import { crosswordPuzzleRegistration } from '../types/crossword-puzzle/register';
51
52
  import { findWordsInMatrixRegistration } from '../types/find-words-in-matrix/register';
53
+ import { sortWordsIntoCategoriesRegistration } from '../types/sort-words-into-categories/register';
52
54
  import { matchColumnsToMakeSentencesRegistration } from '../types/match-columns-to-make-sentences/register';
53
55
  import { matchWordToPictureGroupRegistration } from '../types/match-word-to-picture-group/register';
54
56
  import { wordOrderingGroupRegistration } from '../types/word-ordering-group/register';
@@ -58,6 +60,7 @@ export const questionTypeRegistry = {
58
60
  CHOOSE_THE_CORRECT_ANSWER_GROUP: chooseTheCorrectAnswerGroupRegistration,
59
61
  TRUE_FALSE_GROUP: trueFalseGroupRegistration,
60
62
  TRUE_FALSE_CORRECT_GROUP: trueFalseCorrectGroupRegistration,
63
+ CHOOSE_THEN_ANSWER_GROUP: chooseThenAnswerGroupRegistration,
61
64
  FILL_IN_BLANK_GROUP: fillInBlankGroupRegistration,
62
65
  CROSS_OUT_WORD_GROUP: crossOutWordGroupRegistration,
63
66
  WORD_ORDER_AND_MATCH_GROUP: wordOrderAndMatchGroupRegistration,
@@ -104,6 +107,7 @@ export const questionTypeRegistry = {
104
107
  WORD_FILL_STRUCTURED_FORM: wordFillStructuredFormRegistration,
105
108
  CROSSWORD_PUZZLE: crosswordPuzzleRegistration,
106
109
  FIND_WORDS_IN_MATRIX: findWordsInMatrixRegistration,
110
+ SORT_WORDS_INTO_CATEGORIES: sortWordsIntoCategoriesRegistration,
107
111
  MATCH_COLUMNS_TO_MAKE_SENTENCES: matchColumnsToMakeSentencesRegistration,
108
112
  MATCH_WORD_TO_PICTURE_GROUP: matchWordToPictureGroupRegistration,
109
113
  WORD_ORDERING_GROUP: wordOrderingGroupRegistration,
@@ -5,21 +5,45 @@ import { Input } from '../../../../components/ui/input';
5
5
  import { Label } from '../../../../components/ui/label';
6
6
  import { CheckCircle2, XCircle, BookOpen } from 'lucide-react';
7
7
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
8
+ import { toAnswerTheQuestionImageUrls, } from '../../_shared/types/answer-the-question.type';
8
9
  import { useState } from 'react';
10
+ function isDisplayableMediaUrl(value) {
11
+ return (value.startsWith('http://')
12
+ || value.startsWith('https://')
13
+ || value.startsWith('blob:')
14
+ || value.startsWith('data:')
15
+ || value.startsWith('/'));
16
+ }
17
+ function MediaPreview({ src, alt }) {
18
+ const { previewUrl, isLoading } = usePresignedFileUrl(src);
19
+ if (isLoading) {
20
+ return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
21
+ }
22
+ const url = previewUrl || (isDisplayableMediaUrl(src) ? src : '');
23
+ if (!url) {
24
+ return null;
25
+ }
26
+ return (_jsx("img", { src: url, alt: alt, className: "h-auto max-h-80 w-full object-contain", onError: (event) => {
27
+ const target = event.target;
28
+ target.onerror = null;
29
+ target.src = '/images/placeholder-image.svg';
30
+ } }));
31
+ }
9
32
  export function AnswerTheQuestionClient({ questionData, onSubmit, isReviewMode = false, userAnswer, autoFillCorrectAnswers = false, explanation, }) {
10
33
  const [answer, setAnswer] = useState(userAnswer || '');
11
- const { previewUrl: imagePreviewUrl } = usePresignedFileUrl(questionData.imageUrl || '');
12
- if (!questionData.question) {
34
+ const imageUrls = toAnswerTheQuestionImageUrls(questionData.imageUrl);
35
+ if (!questionData.question && imageUrls.length === 0) {
13
36
  return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("p", { className: "text-gray-500", children: "Kh\u00F4ng c\u00F3 d\u1EEF li\u1EC7u c\u00E2u h\u1ECFi" }) }) }));
14
37
  }
15
38
  const isExample = questionData.isExample || false;
39
+ const correctAnswer = questionData.correctAnswer || '';
16
40
  const displayedAnswer = autoFillCorrectAnswers || isExample
17
- ? questionData.correctAnswer
41
+ ? correctAnswer
18
42
  : answer;
19
43
  const isCorrect = isReviewMode && userAnswer !== undefined
20
- ? userAnswer.trim().toLowerCase() === questionData.correctAnswer.trim().toLowerCase()
44
+ ? userAnswer.trim().toLowerCase() === correctAnswer.trim().toLowerCase()
21
45
  : undefined;
22
- return (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-teal-400 to-cyan-500" }), _jsxs(CardContent, { className: "p-6 space-y-6", children: [(questionData.title || questionData.groupContent) && (_jsxs("div", { className: "rounded-xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50 p-4 space-y-2", children: [questionData.title && (_jsx("h3", { className: "text-base font-bold text-indigo-900", children: questionData.title })), questionData.groupContent && (_jsx("p", { className: "text-sm text-indigo-700 leading-relaxed whitespace-pre-wrap", children: questionData.groupContent }))] })), (imagePreviewUrl || questionData.imageUrl) && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "relative max-w-lg overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm", children: _jsx("img", { src: imagePreviewUrl || questionData.imageUrl, alt: "Question", className: "h-auto max-h-80 w-full object-contain" }) }) })), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5", children: [isExample && (_jsxs("div", { className: "mb-2 inline-flex items-center gap-1.5 rounded-full bg-amber-100 border border-amber-300 px-3 py-1", children: [_jsx(BookOpen, { className: "h-3.5 w-3.5 text-amber-600" }), _jsx("span", { className: "text-xs font-semibold text-amber-700 uppercase tracking-wide", children: "Example" })] })), _jsx(Label, { className: "text-base font-semibold text-gray-800 mb-3 block", children: questionData.question }), questionData.suggestedAnswers && questionData.suggestedAnswers.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 mt-2 mb-1", children: questionData.suggestedAnswers.map((suggestion, index) => (_jsx("button", { type: "button", disabled: isReviewMode || isExample, onClick: () => {
46
+ return (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-teal-400 to-cyan-500" }), _jsxs(CardContent, { className: "p-6 space-y-6", children: [(questionData.title || questionData.groupContent) && (_jsxs("div", { className: "rounded-xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50 p-4 space-y-2", children: [questionData.title && (_jsx("h3", { className: "text-base font-bold text-indigo-900", children: questionData.title })), questionData.groupContent && (_jsx("p", { className: "text-sm text-indigo-700 leading-relaxed whitespace-pre-wrap", children: questionData.groupContent }))] })), imageUrls.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: imageUrls.map((url, imageIndex) => (_jsx("div", { className: "relative max-w-lg overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm", children: _jsx(MediaPreview, { src: url, alt: `Question image ${imageIndex + 1}` }) }, `atq-image-${imageIndex}`))) })), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5", children: [isExample && (_jsxs("div", { className: "mb-2 inline-flex items-center gap-1.5 rounded-full bg-amber-100 border border-amber-300 px-3 py-1", children: [_jsx(BookOpen, { className: "h-3.5 w-3.5 text-amber-600" }), _jsx("span", { className: "text-xs font-semibold text-amber-700 uppercase tracking-wide", children: "Example" })] })), questionData.question ? (_jsx(Label, { className: "text-base font-semibold text-gray-800 mb-3 block", children: questionData.question })) : null, questionData.suggestedAnswers && questionData.suggestedAnswers.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 mt-2 mb-1", children: questionData.suggestedAnswers.map((suggestion, index) => (_jsx("button", { type: "button", disabled: isReviewMode || isExample, onClick: () => {
23
47
  if (!isReviewMode && !isExample) {
24
48
  setAnswer(suggestion);
25
49
  onSubmit?.(suggestion);
@@ -33,5 +57,5 @@ export function AnswerTheQuestionClient({ questionData, onSubmit, isReviewMode =
33
57
  ? isCorrect
34
58
  ? 'border-green-400 bg-green-50 text-green-700'
35
59
  : 'border-red-400 bg-red-50 text-red-700'
36
- : 'border-teal-300 focus:border-teal-500 focus:ring-teal-200'}` }), questionData.suffixText && (_jsx("span", { className: "text-base font-medium text-gray-700", children: questionData.suffixText })), isReviewMode && isCorrect !== undefined && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(CheckCircle2, { className: "h-5 w-5 text-green-500" })) : (_jsx(XCircle, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && isCorrect === false && (_jsxs("div", { className: "mt-3 flex items-center gap-2 rounded-lg bg-green-50 border border-green-200 px-4 py-2", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsxs("span", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: questionData.correctAnswer })] })] })), autoFillCorrectAnswers && (_jsxs("div", { className: "mt-3 flex items-center gap-2 rounded-lg bg-teal-50 border border-teal-200 px-4 py-2", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-teal-600" }), _jsxs("span", { className: "text-sm text-teal-700", children: ["\u0110\u00E1p \u00E1n: ", _jsx("strong", { children: questionData.correctAnswer })] })] }))] }), explanation && isReviewMode && (_jsxs("div", { className: "rounded-xl border border-blue-200 bg-blue-50 p-4", children: [_jsx("p", { className: "text-sm font-semibold text-blue-800 mb-1", children: "Gi\u1EA3i th\u00EDch:" }), _jsx("p", { className: "text-sm text-blue-700", children: explanation })] }))] })] }));
60
+ : 'border-teal-300 focus:border-teal-500 focus:ring-teal-200'}` }), questionData.suffixText && (_jsx("span", { className: "text-base font-medium text-gray-700", children: questionData.suffixText })), isReviewMode && isCorrect !== undefined && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(CheckCircle2, { className: "h-5 w-5 text-green-500" })) : (_jsx(XCircle, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && isCorrect === false && (_jsxs("div", { className: "mt-3 flex items-center gap-2 rounded-lg bg-green-50 border border-green-200 px-4 py-2", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsxs("span", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: correctAnswer })] })] })), autoFillCorrectAnswers && (_jsxs("div", { className: "mt-3 flex items-center gap-2 rounded-lg bg-teal-50 border border-teal-200 px-4 py-2", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-teal-600" }), _jsxs("span", { className: "text-sm text-teal-700", children: ["\u0110\u00E1p \u00E1n: ", _jsx("strong", { children: correctAnswer })] })] }))] }), explanation && isReviewMode && (_jsxs("div", { className: "rounded-xl border border-blue-200 bg-blue-50 p-4", children: [_jsx("p", { className: "text-sm font-semibold text-blue-800 mb-1", children: "Gi\u1EA3i th\u00EDch:" }), _jsx("p", { className: "text-sm text-blue-700", children: explanation })] }))] })] }));
37
61
  }
@@ -0,0 +1,2 @@
1
+ import type { AnswerTheQuestionData } from '../../_shared/types/answer-the-question.type';
2
+ export declare function mapQuestionToAnswerTheQuestionData(question: Record<string, unknown> | null | undefined): AnswerTheQuestionData;
@@ -0,0 +1,56 @@
1
+ import { fromAnswerTheQuestionImageUrls } from '../../_shared/types/answer-the-question.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
+ function asStringArray(value) {
12
+ if (!Array.isArray(value)) {
13
+ return undefined;
14
+ }
15
+ const items = value
16
+ .filter((item) => typeof item === 'string')
17
+ .map((item) => item.trim())
18
+ .filter((item) => item !== '');
19
+ return items.length > 0 ? items : undefined;
20
+ }
21
+ function resolveCorrectAnswer(correct, answer) {
22
+ if (typeof correct.answer === 'string') {
23
+ return correct.answer;
24
+ }
25
+ if (typeof answer.correctAnswer === 'string') {
26
+ return answer.correctAnswer;
27
+ }
28
+ if (typeof answer.answer === 'string') {
29
+ return answer.answer;
30
+ }
31
+ const expected = correct.expectedAnswers ?? answer.expectedAnswers;
32
+ if (Array.isArray(expected) && typeof expected[0] === 'string') {
33
+ return expected[0];
34
+ }
35
+ return '';
36
+ }
37
+ export function mapQuestionToAnswerTheQuestionData(question) {
38
+ const source = asRecord(question);
39
+ const content = asRecord(source.content);
40
+ const answer = asRecord(source.answer);
41
+ const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer);
42
+ const imageUrl = fromAnswerTheQuestionImageUrls(content.imageUrl ?? answer.imageUrl ?? source.imageUrl);
43
+ return {
44
+ question: asString(content.question) || asString(answer.question),
45
+ correctAnswer: resolveCorrectAnswer(correctAnswer, answer),
46
+ suffixText: asString(content.suffixText) || asString(answer.suffixText) || undefined,
47
+ prefixText: asString(content.prefixText) || asString(answer.prefixText) || undefined,
48
+ points: typeof source.points === 'number' ? source.points : undefined,
49
+ isExample: source.isExample === true || answer.isExample === true,
50
+ suggestedAnswers: asStringArray(content.suggestedAnswers) || asStringArray(answer.suggestedAnswers),
51
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
52
+ title: asString(content.title) || asString(answer.title) || undefined,
53
+ groupContent: asString(content.groupContent) || asString(answer.groupContent) || undefined,
54
+ explanation: asString(source.explanation) || asString(answer.explanation) || undefined,
55
+ };
56
+ }
@@ -1,3 +1,4 @@
1
+ import { fromAnswerTheQuestionImageUrls } from '../../_shared/types/answer-the-question.type';
1
2
  export const transformAnswerTheQuestion = (question) => {
2
3
  const answerData = question.answer;
3
4
  if (!answerData?.question) {
@@ -12,14 +13,19 @@ export const transformAnswerTheQuestion = (question) => {
12
13
  if (answerData.prefixText) {
13
14
  apiContent.prefixText = answerData.prefixText;
14
15
  }
15
- // Build basicGroupData when any group field is present (title, imageUrl, groupContent)
16
- const hasGroupData = !!(answerData.imageUrl || answerData.title || answerData.subtitle || answerData.groupContent);
16
+ const imageUrl = fromAnswerTheQuestionImageUrls(answerData.imageUrl);
17
+ if (imageUrl !== undefined) {
18
+ apiContent.imageUrl = imageUrl;
19
+ }
20
+ // Group chrome (title/passage) stays on questionGroup.payload.
21
+ // Per-question image lives on content.imageUrl — same split as FILL_IN_BLANK.
22
+ const hasGroupData = !!(answerData.title || answerData.subtitle || answerData.groupContent);
17
23
  const basicGroupData = hasGroupData
18
24
  ? {
19
25
  title: answerData.title || '',
20
26
  subtitle: answerData.subtitle || '',
21
27
  content: answerData.groupContent || '',
22
- imageUrl: answerData.imageUrl || '',
28
+ imageUrl: '',
23
29
  }
24
30
  : undefined;
25
31
  return {
@@ -1,24 +1,57 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, CircleHelp, Lightbulb, Sparkles, Star } from 'lucide-react';
3
+ import { BookOpen, CircleHelp, Lightbulb, Sparkles, Star, Volume2 } from 'lucide-react';
4
4
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
5
5
  import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
6
- function ClientImageItem({ url, alt }) {
7
- const { previewUrl, isLoading } = usePresignedFileUrl(url);
8
- const src = previewUrl ||
9
- (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
10
- if (!src && !isLoading) {
6
+ function isDisplayableMediaUrl(value) {
7
+ return (value.startsWith('http://')
8
+ || value.startsWith('https://')
9
+ || value.startsWith('blob:')
10
+ || value.startsWith('data:')
11
+ || value.startsWith('/'));
12
+ }
13
+ function isPlayableAudioUrl(value) {
14
+ return (value.startsWith('http://')
15
+ || value.startsWith('https://')
16
+ || value.startsWith('blob:')
17
+ || value.startsWith('data:'));
18
+ }
19
+ function toPresignKey(value) {
20
+ const trimmed = value.trim();
21
+ if (isPlayableAudioUrl(trimmed)) {
22
+ return trimmed;
23
+ }
24
+ return trimmed.replace(/^\/+/, '');
25
+ }
26
+ function MediaPreview({ src, kind = 'image', alt }) {
27
+ const fileKey = kind === 'audio' ? toPresignKey(src) : src;
28
+ const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
29
+ if (isLoading) {
30
+ return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
31
+ }
32
+ const url = previewUrl
33
+ || (kind === 'audio'
34
+ ? (isPlayableAudioUrl(fileKey) ? fileKey : '')
35
+ : (isDisplayableMediaUrl(src) ? src : ''));
36
+ if (!url) {
11
37
  return null;
12
38
  }
13
- return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
39
+ if (kind === 'audio') {
40
+ return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full", preload: "metadata" });
41
+ }
42
+ return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: _jsx("img", { src: url, alt: alt, className: "max-h-56 w-auto object-contain p-2", onError: (event) => {
43
+ const target = event.target;
44
+ target.onerror = null;
45
+ target.src = '/images/placeholder-image.svg';
46
+ } }) }));
14
47
  }
15
48
  export function AnswerTheQuestionGroupClient({ questionData, isReviewMode = false, userAnswers = [], }) {
16
49
  const items = questionData.items || [];
17
- 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 })), toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh (${imageIndex + 1})` }, `group-image-${imageIndex}`))) })), 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 } })] })), items.map((item, itemIndex) => {
50
+ 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" })] })), toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: `Hình ảnh (${imageIndex + 1})` }, `group-image-${imageIndex}`))) })), 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 } })] })), items.map((item, itemIndex) => {
18
51
  const userValue = userAnswers[itemIndex] || '';
19
52
  const expectedAnswers = Array.isArray(item.expectedAnswers)
20
53
  ? item.expectedAnswers.filter((answer) => answer.trim() !== '')
21
54
  : [];
22
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] })), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), _jsx("div", { className: "rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700", children: userValue || (isReviewMode ? '(Chưa trả lời)' : 'Học sinh sẽ nhập câu trả lời tại đây') }), isReviewMode && expectedAnswers.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: "\u0110\u00E1p \u00E1n m\u1EABu (AI)" }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] }))] })] }, `${item.questionNumber}-${itemIndex}`));
55
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] })), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [toAnswerTheQuestionGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), _jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), _jsx("div", { className: "rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700", children: userValue || (isReviewMode ? '(Chưa trả lời)' : 'Học sinh sẽ nhập câu trả lời tại đây') }), isReviewMode && expectedAnswers.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: "\u0110\u00E1p \u00E1n m\u1EABu (AI)" }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] }))] })] }, `${item.questionNumber}-${itemIndex}`));
23
56
  }), 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] }))] }));
24
57
  }
@@ -12,6 +12,7 @@ import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
12
12
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
13
13
  import { Eye, Pencil, Plus, Sparkles, Star, Trash2 } from 'lucide-react';
14
14
  import { AnswerTheQuestionGroupClient } from './AnswerTheQuestionGroupClient';
15
+ import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
15
16
  const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
16
17
  function createEmptyItem(questionNumber) {
17
18
  return {
@@ -38,6 +39,7 @@ function normalizeItems(rawItems) {
38
39
  isExample: item.isExample === true,
39
40
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
40
41
  questionNumber: item.questionNumber || index + 1,
42
+ ...(item.imageUrl !== undefined ? { imageUrl: item.imageUrl } : {}),
41
43
  }));
42
44
  }
43
45
  function sumPoints(items) {
@@ -49,9 +51,8 @@ function isContentEmpty(html) {
49
51
  export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
50
52
  const [instruction, setInstruction] = useState(initialData?.instruction || DEFAULT_INSTRUCTION);
51
53
  const [passage, setPassage] = useState(initialData?.passage || '');
52
- const [imageUrl] = useState(Array.isArray(initialData?.imageUrl)
53
- ? initialData.imageUrl.filter((url) => typeof url === 'string' && url.trim() !== '')
54
- : []);
54
+ const [audioUrl] = useState(initialData?.audioUrl || '');
55
+ const [imageUrl] = useState(toAnswerTheQuestionGroupImageUrls(initialData?.imageUrl));
55
56
  const [items, setItems] = useState(() => normalizeItems(initialData?.items));
56
57
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
57
58
  const [isClientMode, setIsClientMode] = useState(false);
@@ -64,6 +65,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
64
65
  const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
65
66
  instruction: nextInstruction,
66
67
  ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
68
+ ...(audioUrl.trim() ? { audioUrl: audioUrl.trim() } : {}),
67
69
  ...(imageUrl.length ? { imageUrl } : {}),
68
70
  items: nextItems,
69
71
  explanation: nextExplanation,
@@ -81,7 +83,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
81
83
  previousPayloadRef.current = serialized;
82
84
  onUnsavedChangesChange?.(true);
83
85
  debouncedOnChange(payload);
84
- }, [instruction, passage, imageUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
86
+ }, [instruction, passage, audioUrl, imageUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
85
87
  const validate = () => {
86
88
  const nextErrors = [];
87
89
  if (!instruction.trim()) {
@@ -115,7 +117,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
115
117
  },
116
118
  getFormData: () => buildPayload(),
117
119
  };
118
- }, [validationRef, instruction, passage, imageUrl, items, explanation]);
120
+ }, [validationRef, instruction, passage, audioUrl, imageUrl, items, explanation]);
119
121
  const updateItem = (index, patch) => {
120
122
  setItems((prev) => {
121
123
  const next = [...prev];
@@ -1,4 +1,4 @@
1
- import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
1
+ import { fromAnswerTheQuestionGroupImageUrls, toAnswerTheQuestionGroupImageUrls, } from '../../_shared/types/answer-the-question-group.type';
2
2
  const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
3
3
  function isRecord(value) {
4
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -9,6 +9,13 @@ function asRecord(value) {
9
9
  function asString(value, fallback = '') {
10
10
  return typeof value === 'string' ? value : fallback;
11
11
  }
12
+ function asOptionalUrl(value) {
13
+ if (typeof value !== 'string') {
14
+ return undefined;
15
+ }
16
+ const trimmed = value.trim();
17
+ return trimmed !== '' ? trimmed : undefined;
18
+ }
12
19
  function normalizeExpectedAnswers(value) {
13
20
  if (!Array.isArray(value)) {
14
21
  return [];
@@ -38,12 +45,14 @@ export function mapAnswerTheQuestionGroupItem(item, index, fallbackAnswers) {
38
45
  : (normalizeExpectedAnswers(fallbackAnswers).length > 0
39
46
  ? normalizeExpectedAnswers(fallbackAnswers)
40
47
  : ['']);
48
+ const imageUrl = fromAnswerTheQuestionGroupImageUrls(content.imageUrl ?? record.imageUrl);
41
49
  return {
42
50
  question: asString(content.question) || asString(record.question),
43
51
  expectedAnswers,
44
52
  isExample,
45
53
  points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
46
54
  questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
55
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
47
56
  };
48
57
  }
49
58
  export function mapQuestionToAnswerTheQuestionGroupData(question) {
@@ -57,10 +66,13 @@ export function mapQuestionToAnswerTheQuestionGroupData(question) {
57
66
  : [];
58
67
  if (Array.isArray(content.items)) {
59
68
  const items = content.items.map((item, index) => mapAnswerTheQuestionGroupItem(item, index, fallbackAnswers[index]?.expectedAnswers));
60
- const imageUrl = toAnswerTheQuestionGroupImageUrls(asRecord(content.meta).imageUrl);
69
+ const meta = asRecord(content.meta);
70
+ const audioUrl = asOptionalUrl(meta.audioUrl);
71
+ const imageUrl = toAnswerTheQuestionGroupImageUrls(meta.imageUrl);
61
72
  return {
62
- instruction: asString(asRecord(content.meta).instruction) || DEFAULT_INSTRUCTION,
63
- passage: asString(asRecord(content.meta).passage),
73
+ instruction: asString(meta.instruction) || DEFAULT_INSTRUCTION,
74
+ passage: asString(meta.passage),
75
+ ...(audioUrl ? { audioUrl } : {}),
64
76
  ...(imageUrl.length ? { imageUrl } : {}),
65
77
  items,
66
78
  explanation,
@@ -68,10 +80,12 @@ export function mapQuestionToAnswerTheQuestionGroupData(question) {
68
80
  };
69
81
  }
70
82
  if (Array.isArray(answer.items)) {
83
+ const audioUrl = asOptionalUrl(answer.audioUrl);
71
84
  const imageUrl = toAnswerTheQuestionGroupImageUrls(answer.imageUrl);
72
85
  return {
73
86
  instruction: asString(answer.instruction, DEFAULT_INSTRUCTION),
74
87
  passage: asString(answer.passage),
88
+ ...(audioUrl ? { audioUrl } : {}),
75
89
  ...(imageUrl.length ? { imageUrl } : {}),
76
90
  items: answer.items,
77
91
  explanation,
@@ -1,4 +1,4 @@
1
- import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
1
+ import { fromAnswerTheQuestionGroupImageUrls, toAnswerTheQuestionGroupImageUrls, } from '../../_shared/types/answer-the-question-group.type';
2
2
  const CHILD_QUESTION_TYPE = 'ANSWER_THE_QUESTION';
3
3
  const GRADING_TYPE = 'AI';
4
4
  const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
@@ -27,10 +27,12 @@ export const transformAnswerTheQuestionGroup = (question) => {
27
27
  const answerData = question.answer;
28
28
  const items = Array.isArray(answerData?.items) ? answerData.items : [];
29
29
  if (items.length === 0) {
30
+ const emptyAudioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
30
31
  return {
31
32
  apiContent: {
32
33
  meta: {
33
- instruction: DEFAULT_INSTRUCTION,
34
+ instruction: answerData?.instruction || DEFAULT_INSTRUCTION,
35
+ ...(emptyAudioUrl ? { audioUrl: emptyAudioUrl } : {}),
34
36
  gradingType: GRADING_TYPE,
35
37
  isAiGraded: true,
36
38
  },
@@ -41,11 +43,13 @@ export const transformAnswerTheQuestionGroup = (question) => {
41
43
  }
42
44
  const apiItems = items.map((item, index) => {
43
45
  const expectedAnswers = normalizeExpectedAnswers(item.expectedAnswers);
46
+ const imageUrl = fromAnswerTheQuestionGroupImageUrls(item.imageUrl);
44
47
  return {
45
48
  questionType: CHILD_QUESTION_TYPE,
46
49
  ...(item.isExample ? { isExample: true } : {}),
47
50
  content: {
48
51
  question: item.question || '',
52
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
49
53
  },
50
54
  correctAnswer: { expectedAnswers },
51
55
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -53,12 +57,14 @@ export const transformAnswerTheQuestionGroup = (question) => {
53
57
  };
54
58
  });
55
59
  const passage = answerData?.passage?.trim();
60
+ const audioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
56
61
  const imageUrl = toAnswerTheQuestionGroupImageUrls(answerData?.imageUrl);
57
62
  return {
58
63
  apiContent: {
59
64
  meta: {
60
65
  instruction: answerData?.instruction || DEFAULT_INSTRUCTION,
61
66
  ...(!isContentEmpty(passage) ? { passage } : {}),
67
+ ...(audioUrl ? { audioUrl } : {}),
62
68
  ...(imageUrl.length ? { imageUrl } : {}),
63
69
  gradingType: GRADING_TYPE,
64
70
  isAiGraded: true,
@@ -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;