@tinyweb_dev/oe-exam-sdk 1.0.0 → 1.0.1

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 (70) hide show
  1. package/dist/api/exam-questions.d.ts +1 -1
  2. package/dist/api/exam-questions.js +39 -1
  3. package/dist/api/exam-questions.types.d.ts +28 -0
  4. package/dist/api/exam-taking.types.d.ts +1 -0
  5. package/dist/components/exams/ExamCreator.d.ts +7 -1
  6. package/dist/components/exams/ExamCreator.js +57 -21
  7. package/dist/components/exams/ExamPreviewDialog.js +4 -2
  8. package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
  9. package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
  10. package/dist/components/exams/api.d.ts +1 -1
  11. package/dist/components/exams/index.d.ts +1 -1
  12. package/dist/components/exams/take/components/QuestionRenderer.d.ts +6 -1
  13. package/dist/components/exams/take/components/QuestionRenderer.js +4 -4
  14. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
  15. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
  16. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
  17. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
  18. package/dist/components/exams/take/exam-taking.utils.d.ts +1 -0
  19. package/dist/components/exams/take/exam-taking.utils.js +1 -0
  20. package/dist/components/exams/take/index.d.ts +6 -0
  21. package/dist/components/exams/take/index.js +5 -0
  22. package/dist/components/exams/take/utils/question-transformers.d.ts +9 -1
  23. package/dist/components/exams/take/utils/question-transformers.js +68 -4
  24. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
  25. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
  26. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
  27. package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
  28. package/dist/components/questions/_shared/types/question-group.type.js +4 -1
  29. package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
  30. package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
  31. package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
  32. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
  33. package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
  34. package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
  35. package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
  36. package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
  37. package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
  38. package/dist/components/questions/question-bank/index.d.ts +1 -0
  39. package/dist/components/questions/question-bank/index.js +1 -0
  40. package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
  41. package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
  42. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
  43. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
  44. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
  45. package/dist/components/questions/types/fill-in-blank/register.js +5 -0
  46. package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
  47. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
  48. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
  49. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
  50. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
  51. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
  52. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
  53. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
  54. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
  55. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
  56. package/dist/components/questions/viewer/QuestionViewer.js +12 -1
  57. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
  58. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
  59. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  60. package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.js +2 -1
  63. package/dist/shared/constants/ApiConstant.d.ts +1 -0
  64. package/dist/shared/constants/ApiConstant.js +1 -0
  65. package/dist/shared/lib/rich-text.d.ts +4 -0
  66. package/dist/shared/lib/rich-text.js +25 -0
  67. package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
  68. package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
  69. package/dist/shared/lib/utils/question-transform.js +1 -1
  70. package/package.json +1 -1
@@ -15,13 +15,31 @@ import { blankHasAnswer, compactFillInBlankAnswers, formatBlankAnswersDisplay, e
15
15
  import { useDebouncedCallback, usePresignedFileUrl } from '../../../../shared/lib/hooks';
16
16
  import { FileUpload } from '../../../../components/ui/file-upload';
17
17
  import { BasicQuestionGroupCard } from '../../groups/BasicQuestionGroupCard';
18
+ import { ImageLabelGroupCard } from '../../groups/ImageLabelGroupCard';
18
19
  import { useBasicQuestionGroup } from '../../_shared/hooks/useBasicQuestionGroup';
19
- function FillInBlankCreatorContent({ initialData, onChange, externalErrors, validationRef, groups, questionIndexInPart, partId, questionConfig, headerExtra, }) {
20
+ function resolveInitialImageLabels(initialData, suggestedAnswers) {
21
+ const fromPayload = initialData?.imageLabels || initialData?.questionGroup?.payload?.imageLabels;
22
+ if (Array.isArray(fromPayload) && fromPayload.length > 0) {
23
+ return fromPayload.map((item) => ({
24
+ imageUrl: item?.imageUrl || '',
25
+ label: item?.label || '',
26
+ }));
27
+ }
28
+ if (suggestedAnswers?.length) {
29
+ return suggestedAnswers.map((label) => ({ imageUrl: '', label }));
30
+ }
31
+ return [];
32
+ }
33
+ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, validationRef, groups, questionIndexInPart, partId, questionConfig, groupType, suggestedAnswers, headerExtra, }) {
34
+ const effectiveGroupType = (groupType || 'BASIC').toUpperCase();
35
+ const isImageLabelGroup = effectiveGroupType === 'IMAGE_LABEL';
36
+ const initialImageLabels = resolveInitialImageLabels(initialData, suggestedAnswers);
20
37
  // ============ GROUP (shared title / content / image) ============
21
- const { isGrouped, currentGroupNumber, groupTitle, groupSubtitle, groupContent, imageUrl: groupImageUrl, setGroupTitle, setGroupSubtitle, setGroupContent, setImageUrl: setGroupImageUrl, } = useBasicQuestionGroup({
38
+ const { isGrouped, currentGroupNumber, isFirstInGroup, groupTitle, groupSubtitle, groupContent, imageUrl: groupImageUrl, imageLabels, setGroupTitle, setGroupSubtitle, setGroupContent, setImageUrl: setGroupImageUrl, setImageLabels, } = useBasicQuestionGroup({
22
39
  partId,
23
40
  groups,
24
41
  questionIndexInPart,
42
+ imageLabelCount: isImageLabelGroup ? (initialImageLabels.length || 1) : 0,
25
43
  initialData: {
26
44
  title: (() => {
27
45
  if (initialData?.content?.passageTitle)
@@ -60,6 +78,7 @@ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, vali
60
78
  return initialData.groupImageUrl;
61
79
  return '';
62
80
  })(),
81
+ imageLabels: initialImageLabels,
63
82
  },
64
83
  });
65
84
  // ============ STATE ============
@@ -232,10 +251,11 @@ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, vali
232
251
  groupContent: isGrouped ? groupContent : '',
233
252
  groupNumber: currentGroupNumber,
234
253
  groups,
254
+ ...(isGrouped && isImageLabelGroup ? { imageLabels } : {}),
235
255
  }),
236
256
  };
237
257
  }
238
- }, [validationRef, question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, isExample, validateForm]);
258
+ }, [validationRef, question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, isExample, validateForm, isImageLabelGroup, imageLabels]);
239
259
  // ============ SYNC INITIAL DATA ============
240
260
  useEffect(() => {
241
261
  const initialDataChanged = JSON.stringify(previousInitialDataRef.current) !== JSON.stringify(initialData);
@@ -290,6 +310,7 @@ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, vali
290
310
  groupContent,
291
311
  groupNumber: currentGroupNumber,
292
312
  groups,
313
+ ...(isImageLabelGroup ? { imageLabels } : {}),
293
314
  });
294
315
  previousPayloadRef.current = currentPayload;
295
316
  return;
@@ -310,13 +331,14 @@ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, vali
310
331
  groupContent: isGrouped ? groupContent : '',
311
332
  groupNumber: currentGroupNumber,
312
333
  groups,
334
+ ...(isGrouped && isImageLabelGroup ? { imageLabels } : {}),
313
335
  };
314
336
  const payloadString = JSON.stringify(payload);
315
337
  if (previousPayloadRef.current !== payloadString) {
316
338
  previousPayloadRef.current = payloadString;
317
339
  debouncedOnChange(payload);
318
340
  }
319
- }, [question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, isExample, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, debouncedOnChange]);
341
+ }, [question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, isExample, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, debouncedOnChange, isImageLabelGroup, imageLabels]);
320
342
  // Ensure blank slot exists then update alternative at altIndex
321
343
  const updateBlankAnswer = (blankIndex, altIndex, value) => {
322
344
  setAnswers((prev) => {
@@ -394,11 +416,12 @@ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, vali
394
416
  groupContent: isGrouped ? groupContent : '',
395
417
  groupImageUrl: isGrouped ? (groupDisplayPreviewUrl || groupImageUrl) : '',
396
418
  groupNumber: isGrouped ? currentGroupNumber : undefined,
419
+ ...(isGrouped && isImageLabelGroup ? { imageLabels } : {}),
397
420
  };
398
421
  return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { children: _jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx(CardTitle, { children: "Fill In Blank (Preview)" }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Quay l\u1EA1i Edit Mode"] })] }) }) }), _jsx(FillInBlankClient, { questionData: questionData, questionConfig: questionConfig, autoFillCorrectAnswers: true, isReviewMode: false, explanation: explanation })] }));
399
422
  }
400
423
  // ============ CREATOR UI ============
401
- return (_jsxs("div", { className: "space-y-4", children: [isGrouped && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, uploadIdPrefix: `fill-in-blank-${partId ?? 'group'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 shadow-sm", children: _jsx(Type, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" }), _jsx("p", { className: "text-xs text-gray-500", children: "H\u1ED7 tr\u1EE3 nhi\u1EC1u ch\u1ED7 tr\u1ED1ng trong m\u1ED9t c\u00E2u h\u1ECFi" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: !question?.trim() || placeholderIndices.length === 0, className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] })] }) }), _jsxs(CardContent, { className: "space-y-4 px-4 pb-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsx(PointsInput, { value: points, onChange: (e) => setPoints(parseFloat(e.target.value) || 1) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-suggested-words", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-suggested-words", className: "cursor-pointer text-sm font-medium text-gray-700", children: "Hi\u1EC3n th\u1ECB t\u1EEB g\u1EE3i \u00FD" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "isExample", checked: isExample, onCheckedChange: setIsExample }), _jsx(Label, { htmlFor: "isExample", className: "cursor-pointer text-sm font-medium text-gray-700", children: "\u0110\u00E1nh d\u1EA5u l\u00E0 c\u00E2u h\u1ECFi Example" })] })] }), _jsx("div", { className: "rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-5 shadow-sm", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100", children: _jsx(Lightbulb, { className: "h-4 w-4 text-blue-600" }) }), _jsxs("div", { className: "text-sm text-blue-900", children: [_jsx("p", { className: "font-semibold text-blue-800", children: "H\u01B0\u1EDBng d\u1EABn t\u1EA1o c\u00E2u h\u1ECFi" }), _jsxs("ul", { className: "mt-2 space-y-1.5 text-blue-700", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "S\u1EED d\u1EE5ng ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{0}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{1}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{2}' }), "... \u0111\u1EC3 \u0111\u00E1nh d\u1EA5u c\u00E1c ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "H\u1EC7 th\u1ED1ng s\u1EBD t\u1EF1 \u0111\u1ED9ng t\u1EA1o \u00F4 nh\u1EADp \u0111\u00E1p \u00E1n cho m\u1ED7i ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "V\u00ED d\u1EE5: \u201CHe ", '{0}', " 10 years old and she ", '{1}', " 12.\u201D"] })] })] })] }) }), !isGrouped && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-indigo-500" }), "H\u00ECnh \u1EA3nh ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(T\u00F9y ch\u1ECDn)" })] }), _jsx(FileUpload, { id: "fill-in-blank-image", label: "", accept: "image/*", value: imageUrl || '', onChange: (url) => {
424
+ return (_jsxs("div", { className: "space-y-4", children: [isGrouped && isImageLabelGroup && (_jsx(ImageLabelGroupCard, { groupNumber: currentGroupNumber, imageLabels: imageLabels, onImageLabelsChange: setImageLabels, title: groupTitle, onTitleChange: setGroupTitle, isReadOnly: !isFirstInGroup, uploadIdPrefix: `fill-in-blank-${partId ?? 'image-label'}` })), isGrouped && !isImageLabelGroup && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, uploadIdPrefix: `fill-in-blank-${partId ?? 'group'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 shadow-sm", children: _jsx(Type, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" }), _jsx("p", { className: "text-xs text-gray-500", children: "H\u1ED7 tr\u1EE3 nhi\u1EC1u ch\u1ED7 tr\u1ED1ng trong m\u1ED9t c\u00E2u h\u1ECFi" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: !question?.trim() || placeholderIndices.length === 0, className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] })] }) }), _jsxs(CardContent, { className: "space-y-4 px-4 pb-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsx(PointsInput, { value: points, onChange: (e) => setPoints(parseFloat(e.target.value) || 1) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-suggested-words", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-suggested-words", className: "cursor-pointer text-sm font-medium text-gray-700", children: "Hi\u1EC3n th\u1ECB t\u1EEB g\u1EE3i \u00FD" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "isExample", checked: isExample, onCheckedChange: setIsExample }), _jsx(Label, { htmlFor: "isExample", className: "cursor-pointer text-sm font-medium text-gray-700", children: "\u0110\u00E1nh d\u1EA5u l\u00E0 c\u00E2u h\u1ECFi Example" })] })] }), _jsx("div", { className: "rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-5 shadow-sm", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100", children: _jsx(Lightbulb, { className: "h-4 w-4 text-blue-600" }) }), _jsxs("div", { className: "text-sm text-blue-900", children: [_jsx("p", { className: "font-semibold text-blue-800", children: "H\u01B0\u1EDBng d\u1EABn t\u1EA1o c\u00E2u h\u1ECFi" }), _jsxs("ul", { className: "mt-2 space-y-1.5 text-blue-700", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "S\u1EED d\u1EE5ng ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{0}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{1}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{2}' }), "... \u0111\u1EC3 \u0111\u00E1nh d\u1EA5u c\u00E1c ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "H\u1EC7 th\u1ED1ng s\u1EBD t\u1EF1 \u0111\u1ED9ng t\u1EA1o \u00F4 nh\u1EADp \u0111\u00E1p \u00E1n cho m\u1ED7i ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "V\u00ED d\u1EE5: \u201CHe ", '{0}', " 10 years old and she ", '{1}', " 12.\u201D"] })] })] })] }) }), !isGrouped && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-indigo-500" }), "H\u00ECnh \u1EA3nh ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(T\u00F9y ch\u1ECDn)" })] }), _jsx(FileUpload, { id: "fill-in-blank-image", label: "", accept: "image/*", value: imageUrl || '', onChange: (url) => {
402
425
  setImageUrl(url);
403
426
  }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), includesAudio && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Volume2, { className: "h-4 w-4 text-indigo-500" }), "Audio c\u00E2u h\u1ECFi"] }), _jsx(FileUpload, { id: "fill-in-blank-audio", label: "", accept: "audio/*", value: audioUrl || '', onChange: (url) => {
404
427
  setAudioUrl(url);
@@ -130,6 +130,9 @@ export const fillInBlankRegistration = {
130
130
  groups: answer?.groups || initialData?.groups,
131
131
  };
132
132
  }
133
+ if (creatorData) {
134
+ creatorData.imageLabels = groupPayload?.imageLabels || answer?.imageLabels || undefined;
135
+ }
133
136
  return creatorData;
134
137
  },
135
138
  getExtraProps: (ctx) => ({
@@ -139,6 +142,8 @@ export const fillInBlankRegistration = {
139
142
  questionIndexInPart: ctx.questionIndexInPart,
140
143
  partId: ctx.partId,
141
144
  questionConfig: ctx.questionConfig,
145
+ groupType: ctx.groupType,
146
+ suggestedAnswers: ctx.suggestedAnswers,
142
147
  }),
143
148
  wrapOnSave: (data, ctx) => ({
144
149
  type: ctx.questionType,
@@ -4,13 +4,16 @@ export const transformFillInBlank = (question) => {
4
4
  // Helper: build basicGroupData if any group field is present.
5
5
  // NOTE: only `groupImageUrl` belongs to the group card — the per-question `imageUrl`
6
6
  // goes into content (same split as CHOOSE_THE_CORRECT_ANSWER).
7
- const hasGroupData = !!(answerData?.title || answerData?.subtitle || answerData?.groupContent || answerData?.groupImageUrl);
7
+ const imageLabels = Array.isArray(answerData?.imageLabels) ? answerData.imageLabels : undefined;
8
+ const hasImageLabels = Array.isArray(imageLabels) && imageLabels.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
9
+ const hasGroupData = !!(answerData?.title || answerData?.subtitle || answerData?.groupContent || answerData?.groupImageUrl || hasImageLabels);
8
10
  const basicGroupData = hasGroupData
9
11
  ? {
10
12
  title: answerData.title || '',
11
13
  subtitle: answerData.subtitle || '',
12
14
  content: answerData.groupContent || '',
13
15
  imageUrl: answerData.groupImageUrl || '',
16
+ ...(hasImageLabels ? { imageLabels } : {}),
14
17
  }
15
18
  : undefined;
16
19
  const groupNumber = answerData?.groupNumber || 1;
@@ -0,0 +1,11 @@
1
+ export interface SpeakingCueCardClientProps {
2
+ questionData: {
3
+ cueCardHtml: string;
4
+ isBackup?: boolean;
5
+ expectedAnswers?: string[];
6
+ explanation?: string;
7
+ };
8
+ isReviewMode?: boolean;
9
+ }
10
+ export declare function mapQuestionToSpeakingCueCardData(question: Record<string, unknown> | null | undefined): SpeakingCueCardClientProps['questionData'];
11
+ export declare function SpeakingCueCardClient({ questionData, isReviewMode, }: SpeakingCueCardClientProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Mic, CheckCircle2, Info, CreditCard } from 'lucide-react';
4
+ import { RichTextHtml } from '../../../../components/shared/RichTextHtml';
5
+ function asRecord(value) {
6
+ return value && typeof value === 'object' && !Array.isArray(value)
7
+ ? value
8
+ : {};
9
+ }
10
+ export function mapQuestionToSpeakingCueCardData(question) {
11
+ const source = asRecord(question);
12
+ const content = asRecord(source.content);
13
+ const answer = asRecord(source.correctAnswer ?? source.correct_answer ?? source.answer);
14
+ const expectedAnswers = Array.isArray(answer.expectedAnswers)
15
+ ? answer.expectedAnswers.filter((item) => typeof item === 'string' && item.trim() !== '')
16
+ : [];
17
+ return {
18
+ cueCardHtml: typeof content.cueCardHtml === 'string' ? content.cueCardHtml : '',
19
+ isBackup: content.isBackup === true,
20
+ expectedAnswers,
21
+ explanation: typeof source.explanation === 'string' ? source.explanation : '',
22
+ };
23
+ }
24
+ export function SpeakingCueCardClient({ questionData, isReviewMode = false, }) {
25
+ const answers = questionData.expectedAnswers || [];
26
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.isBackup && (_jsxs("div", { className: "inline-flex self-center items-center gap-1.5 px-3 py-1.5 rounded-md bg-amber-50 text-amber-700 border border-amber-200 text-xs font-bold shadow-sm", children: [_jsx("span", { children: "\uD83D\uDD04" }), " C\u00E2u h\u1ECFi d\u1EF1 ph\u00F2ng"] })), _jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsx("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-purple-50 to-indigo-50 px-5 py-3", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-indigo-600 text-white shadow-sm", children: _jsx(CreditCard, { className: "h-4 w-4" }) }), _jsx("span", { className: "text-sm font-medium text-gray-700", children: "Th\u1EBB g\u1EE3i \u00FD (Cue Card)" })] }) }), _jsxs("div", { className: "p-5 space-y-5", children: [_jsx("div", { className: "rounded-xl border border-purple-100 bg-purple-50/40 px-5 py-4", children: _jsx(RichTextHtml, { html: questionData.cueCardHtml, className: "text-sm text-slate-800", fallback: _jsx("p", { className: "text-sm text-gray-400", children: "Ch\u01B0a c\u00F3 n\u1ED9i dung cue card" }) }) }), _jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50/80 px-4 py-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-purple-50 ring-2 ring-purple-100/50", children: _jsx(Mic, { className: "w-4 h-4 text-purple-400" }) }), _jsx("p", { className: "text-xs text-gray-400", children: "H\u1ECDc sinh \u0111\u1ECDc th\u1EBB g\u1EE3i \u00FD, chu\u1EA9n b\u1ECB, r\u1ED3i tr\u1EA3 l\u1EDDi b\u1EB1ng gi\u1ECDng n\u00F3i" })] }), isReviewMode && answers.length > 0 && (_jsxs("div", { className: "rounded-xl border border-green-200 bg-green-50/80 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsx("h4", { className: "text-sm font-semibold text-green-800", children: "\u0110\u00E1p \u00E1n m\u1EABu" })] }), _jsx("div", { className: "space-y-2", children: answers.map((answer, index) => (_jsxs("div", { className: "flex items-center gap-2.5 rounded-lg bg-white/70 border border-green-100 px-3 py-2", children: [_jsx("span", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 text-xs font-bold", children: index + 1 }), _jsx("span", { className: "text-sm text-green-800", children: answer })] }, `${answer}-${index}`))) })] })), isReviewMode && questionData.explanation && (_jsxs("div", { className: "rounded-xl border border-amber-200 bg-amber-50 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Info, { className: "h-4 w-4 text-amber-600" }), _jsx("p", { className: "text-sm font-semibold text-amber-800", children: "Gi\u1EA3i th\u00EDch" })] }), _jsx("p", { className: "text-sm text-amber-700 leading-relaxed whitespace-pre-wrap", children: questionData.explanation })] }))] })] })] }));
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { SpontaneousQAClientProps } from '../../_shared/types/spontaneous-qa.type';
2
+ export declare function SpontaneousQAClient({ questionData, isReviewMode, }: SpontaneousQAClientProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Mic, CheckCircle2, Info, FileQuestion, Volume2 } from 'lucide-react';
4
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
5
+ function sampleAnswers(questionData) {
6
+ const fromExpected = (questionData.expectedAnswers || []).filter((item) => item.trim() !== '');
7
+ if (fromExpected.length > 0)
8
+ return fromExpected;
9
+ return (questionData.sampleAnswers || []).filter((item) => item.trim() !== '');
10
+ }
11
+ export function SpontaneousQAClient({ questionData, isReviewMode = false, }) {
12
+ const { previewUrl: questionAudioUrl } = usePresignedFileUrl(questionData.audioUrl || '');
13
+ const answers = sampleAnswers(questionData);
14
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.isBackup && (_jsxs("div", { className: "inline-flex self-center items-center gap-1.5 px-3 py-1.5 rounded-md bg-amber-50 text-amber-700 border border-amber-200 text-xs font-bold shadow-sm", children: [_jsx("span", { children: "\uD83D\uDD04" }), " C\u00E2u h\u1ECFi d\u1EF1 ph\u00F2ng"] })), _jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsx("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-indigo-50 to-blue-50 px-5 py-3", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-blue-600 text-white shadow-sm", children: _jsx(Mic, { className: "h-4 w-4" }) }), _jsx("span", { className: "text-sm font-medium text-gray-700", children: "H\u1ECFi \u0111\u00E1p t\u1EF1 ph\u00E1t" })] }) }), _jsxs("div", { className: "p-5 space-y-5", children: [questionData.questionText && (_jsxs("div", { className: "flex gap-3", children: [_jsx(FileQuestion, { className: "h-5 w-5 text-indigo-500 shrink-0 mt-0.5" }), _jsx("div", { className: "text-base font-semibold text-gray-800", children: questionData.questionText })] })), questionAudioUrl ? (_jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-blue-100 bg-blue-50/60 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 text-blue-500 shrink-0" }), _jsx("audio", { controls: true, src: questionAudioUrl, className: "h-8 w-full max-w-md", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." })] })) : questionData.audioUrl ? (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 text-gray-400" }), _jsx("span", { className: "text-xs text-gray-400", children: "\u0110ang t\u1EA3i audio c\u00E2u h\u1ECFi..." })] })) : (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 text-gray-400" }), _jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 audio c\u00E2u h\u1ECFi" })] })), _jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50/80 px-4 py-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-indigo-50 ring-2 ring-indigo-100/50", children: _jsx(Mic, { className: "w-4 h-4 text-indigo-400" }) }), _jsx("p", { className: "text-xs text-gray-400", children: "H\u1ECDc sinh nghe c\u00E2u h\u1ECFi r\u1ED3i nh\u1EA5n micro \u0111\u1EC3 tr\u1EA3 l\u1EDDi b\u1EB1ng gi\u1ECDng n\u00F3i" })] }), isReviewMode && answers.length > 0 && (_jsxs("div", { className: "rounded-xl border border-green-200 bg-green-50/80 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsx("h4", { className: "text-sm font-semibold text-green-800", children: "\u0110\u00E1p \u00E1n m\u1EABu" })] }), _jsx("div", { className: "space-y-2", children: answers.map((answer, index) => (_jsxs("div", { className: "flex items-center gap-2.5 rounded-lg bg-white/70 border border-green-100 px-3 py-2", children: [_jsx("span", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 text-xs font-bold", children: index + 1 }), _jsx("span", { className: "text-sm text-green-800", children: answer })] }, `${answer}-${index}`))) })] })), isReviewMode && questionData.explanation && (_jsxs("div", { className: "rounded-xl border border-amber-200 bg-amber-50 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Info, { className: "h-4 w-4 text-amber-600" }), _jsx("p", { className: "text-sm font-semibold text-amber-800", children: "Gi\u1EA3i th\u00EDch" })] }), _jsx("p", { className: "text-sm text-amber-700 leading-relaxed whitespace-pre-wrap", children: questionData.explanation })] }))] })] })] }));
15
+ }
@@ -0,0 +1,2 @@
1
+ import type { SpontaneousQAClientProps } from '../../_shared/types/spontaneous-qa.type';
2
+ export declare function mapQuestionToSpontaneousQAData(question: Record<string, unknown> | null | undefined): SpontaneousQAClientProps['questionData'];
@@ -0,0 +1,33 @@
1
+ function asRecord(value) {
2
+ return value && typeof value === 'object' && !Array.isArray(value)
3
+ ? value
4
+ : {};
5
+ }
6
+ function asStringList(value) {
7
+ if (Array.isArray(value)) {
8
+ return value.filter((item) => typeof item === 'string' && item.trim() !== '');
9
+ }
10
+ if (typeof value === 'string' && value.trim())
11
+ return [value];
12
+ return [];
13
+ }
14
+ export function mapQuestionToSpontaneousQAData(question) {
15
+ const source = asRecord(question);
16
+ const content = asRecord(source.content);
17
+ const answer = asRecord(source.correctAnswer ?? source.correct_answer ?? source.answer);
18
+ const expectedAnswers = asStringList(answer.expectedAnswers).length
19
+ ? asStringList(answer.expectedAnswers)
20
+ : asStringList(answer.sampleAnswers).length
21
+ ? asStringList(answer.sampleAnswers)
22
+ : asStringList(answer.sampleAnswer);
23
+ return {
24
+ inputType: content.inputType === 'AUDIO' ? 'AUDIO' : 'TEXT',
25
+ audioUrl: typeof content.audioUrl === 'string' ? content.audioUrl : '',
26
+ questionText: typeof content.questionText === 'string' ? content.questionText : '',
27
+ expectedAnswers,
28
+ sampleAnswers: expectedAnswers,
29
+ explanation: typeof source.explanation === 'string' ? source.explanation : '',
30
+ isBackup: content.isBackup === true,
31
+ points: typeof source.points === 'number' ? source.points : undefined,
32
+ };
33
+ }
@@ -58,6 +58,7 @@ export function mapQuestionToWfsfData(question) {
58
58
  const source = asRecord(question);
59
59
  const apiContent = asRecord(source.content);
60
60
  const answer = asRecord(source.answer);
61
+ const meta = asRecord(source.meta);
61
62
  const correctAnswer = asRecord(source.correctAnswer ??
62
63
  source.correct_answer ??
63
64
  answer.correctAnswer ??
@@ -66,7 +67,13 @@ export function mapQuestionToWfsfData(question) {
66
67
  ? parseFloat(String(source.total_points))
67
68
  : answer.points ?? source.points ?? 1;
68
69
  const points = Number.isFinite(Number(pointsRaw)) ? Number(pointsRaw) : 1;
69
- const instruction = String(apiContent.instruction || answer.instruction || source.instruction || source.explanation || '');
70
+ // Pearson bank stores rubric on meta.instruction; school/API use content.instruction.
71
+ const instruction = String(apiContent.instruction ||
72
+ answer.instruction ||
73
+ source.instruction ||
74
+ meta.instruction ||
75
+ source.explanation ||
76
+ '');
70
77
  const explanation = String(answer.explanation || '');
71
78
  const content = resolveHtmlContent(apiContent, answer);
72
79
  const rawWordBank = apiContent.wordBank ?? answer.wordBank;
@@ -34,6 +34,10 @@ export const transformWordFillStructuredForm = (question) => {
34
34
  audioUrls,
35
35
  content: answerData.content,
36
36
  };
37
+ const instructionText = String(answerData.instruction ?? '').trim();
38
+ if (instructionText) {
39
+ apiContent.instruction = instructionText;
40
+ }
37
41
  if (answerData.blankStyle) {
38
42
  apiContent.blankStyle = answerData.blankStyle;
39
43
  }
@@ -1,49 +1,67 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { Check, X, ArrowUpDown, Lightbulb } from 'lucide-react';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  import { cn } from '../../../../shared/lib/utils';
7
- export function WordOrderingClient({ questionData, onSubmit, isReviewMode = false, userAnswer, autoFillCorrectAnswers = false, explanation, }) {
8
- const { words = [], answer = [], isExample, imageUrl } = questionData;
9
- // State: the user's current arrangement
10
- const [selectedWords, setSelectedWords] = useState(userAnswer || (autoFillCorrectAnswers ? answer : []));
11
- // Words remaining in the pool
12
- const [availableWords, setAvailableWords] = useState(() => {
13
- const initial = userAnswer || (autoFillCorrectAnswers ? answer : []);
14
- if (initial.length > 0) {
15
- // Remove used words from available pool
16
- const remaining = [...words];
17
- initial.forEach((w) => {
18
- const idx = remaining.indexOf(w);
19
- if (idx >= 0)
20
- remaining.splice(idx, 1);
21
- });
22
- return remaining;
7
+ function toWordList(value) {
8
+ if (Array.isArray(value)) {
9
+ return value.filter((item) => typeof item === 'string');
10
+ }
11
+ if (value && typeof value === 'object') {
12
+ const nested = value.answer;
13
+ if (Array.isArray(nested)) {
14
+ return nested.filter((item) => typeof item === 'string');
23
15
  }
24
- return [...words];
16
+ }
17
+ return [];
18
+ }
19
+ function resolveSelectedWords(userAnswer, answer, autoFillCorrectAnswers) {
20
+ if (Array.isArray(userAnswer)) {
21
+ return toWordList(userAnswer);
22
+ }
23
+ const nested = toWordList(userAnswer);
24
+ if (nested.length > 0) {
25
+ return nested;
26
+ }
27
+ return autoFillCorrectAnswers ? answer : [];
28
+ }
29
+ function remainingWords(words, selected) {
30
+ const remaining = [...words];
31
+ selected.forEach((word) => {
32
+ const idx = remaining.indexOf(word);
33
+ if (idx >= 0)
34
+ remaining.splice(idx, 1);
25
35
  });
36
+ return remaining;
37
+ }
38
+ export function WordOrderingClient({ questionData, onSubmit, isReviewMode = false, userAnswer, autoFillCorrectAnswers = false, explanation, }) {
39
+ const words = useMemo(() => toWordList(questionData.words), [questionData.words]);
40
+ const answer = useMemo(() => toWordList(questionData.answer), [questionData.answer]);
41
+ const { isExample, imageUrl } = questionData;
42
+ const initialSelected = resolveSelectedWords(userAnswer, answer, autoFillCorrectAnswers);
43
+ const [selectedWords, setSelectedWords] = useState(initialSelected);
44
+ const [availableWords, setAvailableWords] = useState(() => remainingWords(words, initialSelected));
26
45
  const { previewUrl: imagePreviewUrl, isLoading } = usePresignedFileUrl(imageUrl || '');
27
- // Sync with userAnswer changes
28
46
  useEffect(() => {
29
- if (userAnswer) {
30
- setSelectedWords(userAnswer);
31
- const remaining = [...words];
32
- userAnswer.forEach((w) => {
33
- const idx = remaining.indexOf(w);
34
- if (idx >= 0)
35
- remaining.splice(idx, 1);
36
- });
37
- setAvailableWords(remaining);
38
- }
39
- }, [userAnswer]);
40
- // Reset when words change (question navigation)
47
+ if (userAnswer == null)
48
+ return;
49
+ if (!Array.isArray(userAnswer) && toWordList(userAnswer).length === 0)
50
+ return;
51
+ const next = resolveSelectedWords(userAnswer, answer, false);
52
+ setSelectedWords(next);
53
+ setAvailableWords(remainingWords(words, next));
54
+ }, [userAnswer, words, answer]);
55
+ const wordsString = JSON.stringify(words);
56
+ const hasUserAnswer = Array.isArray(userAnswer)
57
+ ? true
58
+ : toWordList(userAnswer).length > 0;
41
59
  useEffect(() => {
42
- if (!userAnswer && !autoFillCorrectAnswers) {
60
+ if (!hasUserAnswer && !autoFillCorrectAnswers) {
43
61
  setSelectedWords([]);
44
62
  setAvailableWords([...words]);
45
63
  }
46
- }, [JSON.stringify(words)]);
64
+ }, [wordsString, hasUserAnswer, autoFillCorrectAnswers, words]);
47
65
  const handleSelectWord = useCallback((word, poolIndex) => {
48
66
  if (isReviewMode)
49
67
  return;
@@ -32,6 +32,9 @@ import { FillInBlankGroupViewer } from './FillInBlankGroupViewer';
32
32
  import { MatchingWithLinesGroupViewer } from './MatchingWithLinesGroupViewer';
33
33
  import { CrossOutWordGroupViewer } from './CrossOutWordGroupViewer';
34
34
  import { SpontaneousQaGroupViewer } from './SpontaneousQaGroupViewer';
35
+ import { SpontaneousQAClient } from '../types/spontaneous-qa/SpontaneousQAClient';
36
+ import { mapQuestionToSpontaneousQAData } from '../types/spontaneous-qa/map-spontaneous-qa-data';
37
+ import { SpeakingCueCardClient, mapQuestionToSpeakingCueCardData, } from '../types/speaking-cue-card/SpeakingCueCardClient';
35
38
  import { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
36
39
  import { AnswerTheQuestionViewer } from './AnswerTheQuestionViewer';
37
40
  import { AnswerTheQuestionGroupViewer } from './AnswerTheQuestionGroupViewer';
@@ -633,7 +636,11 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
633
636
  return (_jsxs("div", { className: "space-y-3", children: [content.title ? _jsx("h3", { className: "text-center text-lg font-bold uppercase text-slate-800", children: content.title }) : null, content.imageUrl ? (_jsx("div", { className: "flex justify-center", children: _jsx("img", { src: String(content.imageUrl), alt: "", className: "max-h-[180px] rounded-lg object-contain" }) })) : null, _jsx("div", { className: "space-y-2 text-base leading-relaxed text-slate-800", children: paragraph.split('\n').map((line, li) => (_jsx("div", { className: "flex flex-wrap items-baseline", children: renderLine(line, `wfp-${li}`) }, `wfp-l${li}`))) }), isReviewMode && correctAnswers.length > 0 ? (_jsxs("div", { className: "rounded-lg border border-emerald-200 bg-emerald-50/80 p-2.5 text-sm text-emerald-800", children: [_jsx("span", { className: "font-semibold", children: "\u0110\u00E1p \u00E1n: " }), correctAnswers.map((a, i) => (_jsxs("span", { className: "mr-2", children: ["(", i + 1, ") ", _jsx("span", { className: "font-bold underline", children: a || '—' })] }, `wfp-a${i}`)))] })) : null] }));
634
637
  };
635
638
  const renderQuestion = () => {
636
- switch (question.type) {
639
+ const resolvedType = String(question.type ||
640
+ question.questionType ||
641
+ question.question_type ||
642
+ '');
643
+ switch (resolvedType) {
637
644
  case 'CHOOSE_THE_CORRECT_ANSWER':
638
645
  return renderChooseTheCorrectAnswer();
639
646
  case 'CHOOSE_THE_CORRECT_ANSWER_GROUP':
@@ -654,6 +661,10 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
654
661
  return (_jsx(AnswerTheQuestionViewer, { question: question, isReviewMode: isReviewMode, userAnswer: typeof userAnswer === 'string' ? userAnswer : undefined, onSubmitAnswer: onSubmitAnswer }));
655
662
  case 'ANSWER_THE_QUESTION_GROUP':
656
663
  return (_jsx(AnswerTheQuestionGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer }));
664
+ case 'SPEAKING_CUE_CARD':
665
+ return (_jsx(SpeakingCueCardClient, { questionData: mapQuestionToSpeakingCueCardData(question), isReviewMode: isReviewMode }));
666
+ case 'SPONTANEOUS_QA':
667
+ return (_jsx(SpontaneousQAClient, { questionData: mapQuestionToSpontaneousQAData(question), isReviewMode: isReviewMode }));
657
668
  case 'SPONTANEOUS_QA_GROUP':
658
669
  return (_jsx(SpontaneousQaGroupViewer, { question: question, isReviewMode: isReviewMode }));
659
670
  case 'WORD_ORDER_AND_MATCH_GROUP':
@@ -1,4 +1,5 @@
1
1
  import type { FillBlankQuestion } from '../../../components/exams/take/types';
2
+ import { type ImageLabelData } from '../../questions/_shared/types/question-group.type';
2
3
  interface ReviewListenAndWriteRendererProps {
3
4
  partNumber: number;
4
5
  questionCount: number;
@@ -12,6 +13,7 @@ interface ReviewListenAndWriteRendererProps {
12
13
  questions: FillBlankQuestion[];
13
14
  answers: Record<string, unknown>;
14
15
  isCorrectMap: Record<string, boolean | null>;
16
+ imageLabels?: ImageLabelData[];
15
17
  }
16
- export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
18
+ export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, imageLabels, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
17
19
  export {};
@@ -6,7 +6,9 @@ import CambridgeYlePartBanner from '../../../components/themes/cambridge-yle/Cam
6
6
  import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
7
7
  import CambridgeYleAudioPlayer from '../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
8
8
  import { countBlanks, formatAcceptedAnswers, hasAnyBlankValue, isBlankCorrect, toBlankValues, } from '../../../shared/lib/utils/fill-in-blank';
9
- export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }) {
9
+ import { ImageLabelWordBank } from '../../questions/groups/ImageLabelWordBank';
10
+ import { hasImageLabels } from '../../questions/_shared/types/question-group.type';
11
+ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, imageLabels, }) {
10
12
  /**
11
13
  * Get answer status from API's isCorrect field
12
14
  * Returns: 'correct' | 'incorrect' | 'unanswered'
@@ -44,7 +46,7 @@ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instru
44
46
  return 'unanswered';
45
47
  return isBlankCorrect(userValue, accepted) ? 'correct' : 'incorrect';
46
48
  };
47
- return (_jsxs("div", { className: "flex flex-col gap-6 h-full", children: [_jsxs("div", { className: "flex flex-col gap-4 pb-0 pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: true }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: true }), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true })] }), _jsxs("div", { className: "flex flex-col gap-6 pb-6 pl-2 pt-2", children: [_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title }), _jsx("div", { className: "flex flex-col gap-4", children: questions.map((question) => {
49
+ return (_jsxs("div", { className: "flex flex-col gap-6 h-full", children: [_jsxs("div", { className: "flex flex-col gap-4 pb-0 pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: true }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: true }), hasImageLabels(imageLabels) && (_jsx(ImageLabelWordBank, { items: imageLabels, className: "rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm" })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true })] }), _jsxs("div", { className: "flex flex-col gap-6 pb-6 pl-2 pt-2", children: [_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title }), _jsx("div", { className: "flex flex-col gap-4", children: questions.map((question) => {
48
50
  const status = getAnswerStatus(question.id);
49
51
  const userValues = toBlankValues(answers[question.id], countBlanks(question.segments));
50
52
  const renderBlank = (blankIndex) => {
@@ -27,8 +27,8 @@ export function renderDedicatedReviewBody(ctx) {
27
27
  const apiQuestions = asApiQuestions(questions);
28
28
  switch (questionType) {
29
29
  case 'FILL_IN_BLANK': {
30
- const data = transformFillInBlank(apiQuestions);
31
- return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap }));
30
+ const data = transformFillInBlank(apiQuestions, { suggestedAnswers: part.suggestedAnswers });
31
+ return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, imageLabels: data.imageLabels }));
32
32
  }
33
33
  case 'WRITE_CORRECT_VERB_FORM': {
34
34
  const data = transformWriteCorrectVerbForm(apiQuestions);
@@ -13,6 +13,8 @@ import { EcMatchByWritingAnswer } from './EcMatchByWritingAnswer';
13
13
  import { EcAnswerTheQuestion } from './EcAnswerTheQuestion';
14
14
  import { EcWordFillParagraph } from './EcWordFillParagraph';
15
15
  import { EcWritingTask } from './EcWritingTask';
16
+ import { MoversListenAndWriteRenderer } from '../../../components/exams/take/components/question-renderers/MoversListenAndWriteRenderer';
17
+ import { transformFillInBlank } from '../../../components/exams/take/utils/question-transformers';
16
18
  /**
17
19
  * Question types that the branded English Certification theme renders natively
18
20
  * as multiple-choice cards (TOEIC + IELTS MCQ variants such as TRUE/FALSE/NOT GIVEN).
@@ -67,6 +69,12 @@ export function EnglishCertificationQuestionRenderer({ part, partConfig, answers
67
69
  return (_jsx(EcAnswerTheQuestion, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
68
70
  case 'WORD_FILL_PARAGRAPH':
69
71
  return (_jsx(EcWordFillParagraph, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
72
+ case 'FILL_IN_BLANK': {
73
+ const data = transformFillInBlank(part.questions, {
74
+ suggestedAnswers: part.suggestedAnswers,
75
+ });
76
+ return (_jsx(MoversListenAndWriteRenderer, { partNumber: part.partNo ?? 0, partName: part.name, questionCount: part.questions.filter((q) => !q.is_example).length, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.groupImageUrl || data.imageUrl, audioUrl: data.audioUrl || part.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle, imageLabels: data.imageLabels }));
77
+ }
70
78
  case 'WRITE_A_SHORT_LETTER':
71
79
  return (_jsx(EcWritingTask, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
72
80
  default:
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export * as Results from './components/results';
6
6
  export * as Themes from './components/themes';
7
7
  export * as Shared from './shared';
8
8
  export { buildExamTemplatePayload, createContestRoomsApi, createExamCreateApi, createExamEntryApi, createExamQuestionsApi, createExamQuestionsFetchApi, createExamTakingApi, createMockContestRoomsApi, createMockCreateExamApi, createMockExamEntryApi, createMockExamQuestionsApi, createMockExamTakingApi, createMockResultReviewApi, createResultReviewApi } from './api';
9
- export type { AnswerGradingStatusItem, AnswersGradingStatusResult, ContestRoomListItem, ContestRoomRegistrationStatus, ContestRoomVisibility, ContestRoomsContestDetail, ContestRoomsFetchApiOptions, ContestRoomsSdkApi, CreateExamFetchApiOptions, CreateExamPayload, CreateExamSdkApi, ExamFormData, ExamLevelOption, ExamQuestionsSdkApi, ExamResponse, ExamTakingAnswerValue, ExamTakingAttempt, ExamTakingFetchApiOptions, ExamTakingQuestion, ExamTakingQuestionsResult, ExamTakingRoom, ExamTakingSdkApi, ExamTakingTemplatePart, ExamTemplatePayload, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, GradingSummary, QuestionSearchItem, SaveExamInput, SaveExamTakingAnswersInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, SubmitExamTakingAttemptResult, UpdateExamTemplateInput, UpdatePartAudioInput, UploadAudioAnswerResult } from './api';
9
+ export type { AnswerGradingStatusItem, AnswersGradingStatusResult, ContestRoomListItem, ContestRoomRegistrationStatus, ContestRoomVisibility, ContestRoomsContestDetail, ContestRoomsFetchApiOptions, ContestRoomsSdkApi, CreateExamFetchApiOptions, CreateExamPayload, CreateExamSdkApi, ExamFormData, ExamLevelOption, ExamQuestionsSdkApi, ExamResponse, ExamTakingAnswerValue, ExamTakingAttempt, ExamTakingFetchApiOptions, ExamTakingQuestion, ExamTakingQuestionsResult, ExamTakingRoom, ExamTakingSdkApi, ExamTakingTemplatePart, ExamTemplatePayload, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, GradingSummary, QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SaveExamInput, SaveExamTakingAnswersInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, SubmitExamTakingAttemptResult, UpdateExamTemplateInput, UpdatePartAudioInput, UploadAudioAnswerResult } from './api';
10
10
  export { CreateExamPageContainer, ExamPreviewDialog, ExamPreviewMode, ExamQuestionsPageContainer, ExamTakingPageContainer, ExamViewDialog, StudentContestRoomsPageContainer, StudentExamEntryPageContainer } from './components/exams';
11
11
  export type { CreateExamPageContainerProps, CreateExamTexts, ExamPreviewDialogProps, ExamQuestionsPageContainerProps, ExamTakingPageContainerProps, ExamViewDialogExam, ExamViewDialogProps, StudentContestRoomsPageContainerProps, StudentExamEntryPageContainerProps } from './components/exams';
12
12
  export { ResultReviewPageContainer } from './components/results';
@@ -15,8 +15,8 @@ export { QuestionCreator } from './components/questions/creator/QuestionCreator'
15
15
  export type { QuestionCreatorProps } from './components/questions/creator/QuestionCreator';
16
16
  export { QuestionGroupCreator } from './components/questions/creator/QuestionGroupCreator';
17
17
  export type { QuestionGroupCreatorProps } from './components/questions/creator/QuestionGroupCreator';
18
- export { DifficultyLevel, EditQuestionBankDialog, QuestionBankEditorType, QuestionEditorRenderer, } from './components/questions/question-bank';
19
- export type { EditQuestionBankDialogProps, QuestionBankEditItem, QuestionEditorChangeData, QuestionEditorRendererProps, SkillType as QuestionBankSkillType, UpdateQuestionBankDto, } from './components/questions/question-bank';
18
+ export { DifficultyLevel, EditQuestionBankDialog, QuestionBankEditorType, QuestionBankPickerDialog, QuestionEditorRenderer, } from './components/questions/question-bank';
19
+ export type { EditQuestionBankDialogProps, QuestionBankEditItem, QuestionBankLessonSuggestion, QuestionBankPickerDefaultFilters, QuestionBankPickerDialogProps, QuestionEditorChangeData, QuestionEditorRendererProps, SkillType as QuestionBankSkillType, UpdateQuestionBankDto, } from './components/questions/question-bank';
20
20
  export { renderDedicatedComponent } from './components/questions/creator/dedicated-component-router';
21
21
  export type { DedicatedComponentRouterProps, ValidationRef, } from './components/questions/creator/dedicated-component-router';
22
22
  export { questionTypeRegistry } from './components/questions/creator/question-type-registry';
@@ -24,6 +24,7 @@ export type { QuestionTypeRegistration, RouterContext, } from './components/ques
24
24
  export { QUESTION_TYPE_CONFIG, QUESTION_TYPES_WITH_DEDICATED_COMPONENTS } from './components/questions/_shared/config/question-types.config';
25
25
  export { validateQuestion } from './components/questions/_shared/utils/question-validation';
26
26
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
27
+ export { transformApiQuestionToFrontend, transformApiQuestionsToFrontend, } from './shared/lib/utils/question-reverse-transform';
27
28
  export type { QuestionFormState } from './components/questions/_shared/hooks/useQuestionFormState';
28
29
  export * from './components/questions/_shared/types';
29
30
  export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, MatchColumnsToMakeSentencesViewer, WordOrderingGroupViewer, SortWordsIntoCategoriesViewer, ChooseThenAnswerGroupViewer, } from './components/questions/viewer';
package/dist/index.js CHANGED
@@ -14,11 +14,12 @@ export { CreateExamPageContainer, ExamPreviewDialog, ExamPreviewMode, ExamQuesti
14
14
  export { ResultReviewPageContainer } from './components/results';
15
15
  export { QuestionCreator } from './components/questions/creator/QuestionCreator';
16
16
  export { QuestionGroupCreator } from './components/questions/creator/QuestionGroupCreator';
17
- export { DifficultyLevel, EditQuestionBankDialog, QuestionBankEditorType, QuestionEditorRenderer, } from './components/questions/question-bank';
17
+ export { DifficultyLevel, EditQuestionBankDialog, QuestionBankEditorType, QuestionBankPickerDialog, QuestionEditorRenderer, } from './components/questions/question-bank';
18
18
  export { renderDedicatedComponent } from './components/questions/creator/dedicated-component-router';
19
19
  export { questionTypeRegistry } from './components/questions/creator/question-type-registry';
20
20
  export { QUESTION_TYPE_CONFIG, QUESTION_TYPES_WITH_DEDICATED_COMPONENTS } from './components/questions/_shared/config/question-types.config';
21
21
  export { validateQuestion } from './components/questions/_shared/utils/question-validation';
22
22
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
23
+ export { transformApiQuestionToFrontend, transformApiQuestionsToFrontend, } from './shared/lib/utils/question-reverse-transform';
23
24
  export * from './components/questions/_shared/types';
24
25
  export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, MatchColumnsToMakeSentencesViewer, WordOrderingGroupViewer, SortWordsIntoCategoriesViewer, ChooseThenAnswerGroupViewer, } from './components/questions/viewer';
@@ -86,6 +86,7 @@ export declare const API_ENDPOINTS: {
86
86
  readonly QUESTION_BANKS: "/api/v1/manage/question-banks";
87
87
  readonly QUESTION_BANK_DETAIL: (id: string) => string;
88
88
  readonly QUESTION_BANKS_STATISTICS: "/api/v1/manage/question-banks/statistics";
89
+ readonly QUESTION_BANKS_FILTERS: "/api/v1/manage/question-banks/filters";
89
90
  readonly SCHEDULES_SUMMARY: "/api/v1/manage/schedules/summary";
90
91
  readonly SCHEDULES_CALENDAR: "/api/v1/manage/schedules/calendar";
91
92
  readonly SCHEDULES: "/api/v1/manage/schedules";
@@ -95,6 +95,7 @@ export const API_ENDPOINTS = {
95
95
  QUESTION_BANKS: `/api/${API_VERSION}/manage/question-banks`,
96
96
  QUESTION_BANK_DETAIL: (id) => `/api/${API_VERSION}/manage/question-banks/${id}`,
97
97
  QUESTION_BANKS_STATISTICS: `/api/${API_VERSION}/manage/question-banks/statistics`,
98
+ QUESTION_BANKS_FILTERS: `/api/${API_VERSION}/manage/question-banks/filters`,
98
99
  // Schedules endpoints
99
100
  SCHEDULES_SUMMARY: `/api/${API_VERSION}/manage/schedules/summary`,
100
101
  SCHEDULES_CALENDAR: `/api/${API_VERSION}/manage/schedules/calendar`,
@@ -1,3 +1,7 @@
1
1
  /** Strip HTML tags/entities so empty TipTap documents (`<p></p>`) count as blank. */
2
2
  export declare function richTextToPlainText(value: string | null | undefined): string;
3
3
  export declare function isRichTextEmpty(value: string | null | undefined): boolean;
4
+ /** True when HTML has marks/blocks beyond a plain paragraph (bold, list, table, …). */
5
+ export declare function hasRichTextFormatting(value: string | null | undefined): boolean;
6
+ /** Show stored TipTap HTML as editable plain text without collapsing line breaks. */
7
+ export declare function htmlToPlainEditorText(value: string | null | undefined): string;