@tinyweb_dev/oe-exam-sdk 1.0.0 → 1.0.2

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 (60) hide show
  1. package/dist/api/exam-taking.types.d.ts +1 -0
  2. package/dist/components/exams/ExamPreviewDialog.js +4 -2
  3. package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
  4. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
  5. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
  6. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +13 -20
  7. package/dist/components/exams/take/exam-taking.utils.d.ts +3 -2
  8. package/dist/components/exams/take/exam-taking.utils.js +3 -0
  9. package/dist/components/exams/take/utils/question-transformers.d.ts +11 -3
  10. package/dist/components/exams/take/utils/question-transformers.js +45 -2
  11. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
  12. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
  13. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
  14. package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
  15. package/dist/components/questions/_shared/types/question-group.type.js +4 -1
  16. package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
  17. package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
  18. package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
  19. package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
  20. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
  21. package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
  22. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
  23. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
  24. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
  25. package/dist/components/questions/types/fill-in-blank/register.js +5 -0
  26. package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
  27. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
  28. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
  29. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
  30. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
  31. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
  32. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
  33. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
  34. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
  35. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
  36. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
  37. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +2 -10
  38. package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -1
  39. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
  40. package/dist/components/questions/viewer/QuestionViewer.js +12 -68
  41. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
  42. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
  43. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  44. package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
  45. package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
  46. package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
  47. package/dist/shared/constants/routes.d.ts +2 -2
  48. package/dist/shared/constants/routes.js +3 -3
  49. package/dist/shared/lib/rich-text.d.ts +4 -0
  50. package/dist/shared/lib/rich-text.js +25 -0
  51. package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
  52. package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
  53. package/dist/shared/lib/utils/question-transform.js +1 -1
  54. package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
  55. package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
  56. package/package.json +1 -1
  57. package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
  58. package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
  59. package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
  60. package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
@@ -52,6 +52,7 @@ export interface ExamTakingTemplatePart {
52
52
  sections?: Array<Record<string, unknown>>;
53
53
  isGroup?: boolean;
54
54
  group?: Record<string, unknown>;
55
+ suggestedAnswers?: string[];
55
56
  }
56
57
  export interface ExamTakingQuestionsResult {
57
58
  examTemplate?: {
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { CheckCircle2, Eye, GraduationCap } from 'lucide-react';
5
5
  import { Button } from '../../components/ui/button';
6
- import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../components/ui/dialog';
6
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
7
7
  import { useT } from '../../shared/lib/i18n';
8
8
  import { cn } from '../../shared/lib/utils';
9
9
  import { ExamPreviewMode } from './exam-preview.mode';
@@ -129,7 +129,9 @@ export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defa
129
129
  scrollToTop();
130
130
  }
131
131
  };
132
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }), _jsxs("div", { className: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-1 shadow-inner", children: [_jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.STUDENT), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.STUDENT
132
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }), _jsx(DialogDescription, { className: "sr-only", children: previewMode === ExamPreviewMode.ANSWERS
133
+ ? t('admin.exams.preview.footerNoteAnswers')
134
+ : t('admin.exams.preview.footerNoteStudent') }), _jsxs("div", { className: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-1 shadow-inner", children: [_jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.STUDENT), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.STUDENT
133
135
  ? 'bg-white text-blue-700 shadow-sm ring-1 ring-black/5'
134
136
  : 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(GraduationCap, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.student') })] }), _jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.ANSWERS), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.ANSWERS
135
137
  ? 'bg-emerald-600 text-white shadow-sm'
@@ -42,8 +42,8 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
42
42
  };
43
43
  switch (questionType) {
44
44
  case 'FILL_IN_BLANK': {
45
- const data = transformFillInBlank(questions);
46
- return (_jsx(MoversListenAndWriteRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, 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: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle }));
45
+ const data = transformFillInBlank(questions, { suggestedAnswers: part.suggestedAnswers });
46
+ return (_jsx(MoversListenAndWriteRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, 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: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle, imageLabels: data.imageLabels }));
47
47
  }
48
48
  case 'WRITE_CORRECT_VERB_FORM': {
49
49
  const data = transformWriteCorrectVerbForm(questions);
@@ -1,4 +1,5 @@
1
1
  import type { FillBlankQuestion } from '../../types';
2
+ import { type ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
2
3
  interface MoversListenAndWriteRendererProps {
3
4
  partNumber: number;
4
5
  partName?: string;
@@ -16,6 +17,7 @@ interface MoversListenAndWriteRendererProps {
16
17
  showHints?: boolean;
17
18
  groupContent?: string;
18
19
  groupTitle?: string;
20
+ imageLabels?: ImageLabelData[];
19
21
  }
20
- export declare function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode, showHints, groupContent, groupTitle, }: MoversListenAndWriteRendererProps): import("react").JSX.Element;
22
+ export declare function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode, showHints, groupContent, groupTitle, imageLabels, }: MoversListenAndWriteRendererProps): import("react").JSX.Element;
21
23
  export {};
@@ -6,6 +6,8 @@ import CambridgeYleInstructionBanner from '../../../../../components/themes/camb
6
6
  import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
7
7
  import CambridgeYleExampleText from '../../../../../components/themes/cambridge-yle/CambridgeYleExampleText';
8
8
  import { countBlanks, formatAcceptedAnswers, getPrimaryAnswer, isBlankCorrect, toBlankValues, } from '../../../../../shared/lib/utils/fill-in-blank';
9
+ import { ImageLabelWordBank } from '../../../../questions/groups/ImageLabelWordBank';
10
+ import { hasImageLabels } from '../../../../questions/_shared/types/question-group.type';
9
11
  /** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
10
12
  function renderTextWithLineBreaks(text) {
11
13
  const lines = text.split('\n');
@@ -23,9 +25,10 @@ function blankInputClassName({ isExampleQ, isReviewMode, isCorrect, isWrong, })
23
25
  }
24
26
  return 'border-blue-200 bg-blue-50/30 text-blue-900 focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-100';
25
27
  }
26
- export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, }) {
28
+ export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, imageLabels, }) {
29
+ const hasImageLabelBank = hasImageLabels(imageLabels);
27
30
  const hasGroupContent = !!(audioUrl || imageUrl || groupContent || exampleText);
28
- return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar relative", children: [_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: isReviewMode })] }), hasGroupContent && (_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-0 sticky top-0 z-20 bg-white/95 backdrop-blur pb-2 shadow-sm border-b border-gray-100", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), _jsxs("div", { className: "max-h-[35vh] overflow-y-auto custom-scrollbar pr-2 flex flex-col gap-3", children: [imageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[200px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsxs("div", { className: "rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 shadow-inner", children: [groupTitle && _jsx("h3", { className: "mb-2 font-semibold text-gray-800", children: groupTitle }), groupContent] }))] }), exampleText && (_jsx(CambridgeYleExampleText, { text: exampleText, highlightedWord: exampleHighlight || '' }))] })), _jsxs("div", { className: "flex flex-col gap-4 p-3 sm:gap-6 sm:pb-6 sm:px-6", children: [title && (_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title })), _jsx("div", { className: "flex flex-col gap-6 sm:gap-8", children: questions.map((question) => {
31
+ return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar relative", children: [_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: isReviewMode }), hasImageLabelBank && (_jsx(ImageLabelWordBank, { items: imageLabels, className: "rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm" }))] }), hasGroupContent && (_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-0 sticky top-0 z-20 bg-white/95 backdrop-blur pb-2 shadow-sm border-b border-gray-100", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), _jsxs("div", { className: "max-h-[35vh] overflow-y-auto custom-scrollbar pr-2 flex flex-col gap-3", children: [imageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[200px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsxs("div", { className: "rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 shadow-inner", children: [groupTitle && _jsx("h3", { className: "mb-2 font-semibold text-gray-800", children: groupTitle }), groupContent] }))] }), exampleText && (_jsx(CambridgeYleExampleText, { text: exampleText, highlightedWord: exampleHighlight || '' }))] })), _jsxs("div", { className: "flex flex-col gap-4 p-3 sm:gap-6 sm:pb-6 sm:px-6", children: [title && (_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title })), _jsx("div", { className: "flex flex-col gap-6 sm:gap-8", children: questions.map((question) => {
29
32
  const isExampleQ = question.isExample || false;
30
33
  const isDisabled = isReviewMode || isExampleQ;
31
34
  const blankCount = countBlanks(question.segments);
@@ -6,6 +6,7 @@ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-y
6
6
  import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
7
7
  import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
8
8
  import { cn } from '../../../../../shared/lib/utils';
9
+ import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, } from '../../../../../shared/lib/utils/wfsf-blank-match';
9
10
  /**
10
11
  * Replace `{key}` placeholders with inline blank controls.
11
12
  * WITH_OPTIONS blanks become <select>; free-type stay <input>.
@@ -51,14 +52,6 @@ function buildHtmlWithInputPlaceholders(html, wordBank) {
51
52
  `;
52
53
  });
53
54
  }
54
- function normalizeStructuredAnswer(value, caseSensitive) {
55
- const normalized = value
56
- .trim()
57
- .replace(/[\p{P}]/gu, '')
58
- .replace(/\s+/g, ' ')
59
- .trim();
60
- return caseSensitive ? normalized : normalized.toLowerCase();
61
- }
62
55
  // Stable empty object to avoid new {} on every render (breaks React.memo)
63
56
  const emptyObj = {};
64
57
  /**
@@ -96,33 +89,33 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
96
89
  const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
97
90
  const hint = container.querySelector(`[data-blank-hint="${key}"]`);
98
91
  const userAnswer = initialAnswersRef.current[key] || '';
99
- const correctAnswer = question.answers[key] || '';
92
+ const correctAnswer = question.answers[key];
93
+ const correctLabel = formatWfsfCorrectLabel(correctAnswer);
100
94
  const hasUserAnswer = userAnswer.trim().length > 0;
101
95
  const isCorrect = hasUserAnswer
102
- && normalizeStructuredAnswer(userAnswer, question.caseSensitive)
103
- === normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
96
+ && isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
104
97
  // Restore saved answer value on mount
105
- const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctAnswer.trim().length > 0;
98
+ const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
106
99
  input.value = shouldReplaceInputWithReviewText
107
- ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctAnswer}`
100
+ ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctLabel}`
108
101
  : userAnswer;
109
102
  input.disabled = isReviewMode;
110
103
  const isSelect = input.tagName === 'SELECT';
111
104
  // Set classes from blankStyle (letter = square box)
112
105
  const blankStyle = question.blankStyle || 'word';
113
106
  const isLetter = blankStyle === 'letter';
114
- const isPhrase = blankStyle === 'phrase';
107
+ const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
115
108
  if (isLetter && !isSelect && 'maxLength' in input) {
116
- input.maxLength = Math.max(1, (correctAnswer || ' ').trim().length || 1);
109
+ const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
110
+ input.maxLength = Math.max(1, maxAltLen || 1);
117
111
  }
118
112
  input.className = cn('structured-blank-input inline-block bg-slate-50/40 text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isSelect
119
113
  ? 'min-w-[5.5rem] max-w-[10rem] rounded border px-2 py-1 text-center'
120
114
  : isLetter
121
115
  ? 'h-7 w-7 rounded border-2 px-0 text-center uppercase'
122
116
  : isPhrase
123
- ? 'min-w-[9rem] max-w-[18rem] w-[14ch] rounded-t border-b-2 px-2 py-0.5 text-center'
124
- : // blankStyle=word short single-token field
125
- 'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
117
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5 text-center'
118
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
126
119
  ? isCorrect
127
120
  ? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
128
121
  : hasUserAnswer
@@ -130,7 +123,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
130
123
  : 'border-amber-500 bg-amber-50 text-amber-800 font-bold'
131
124
  : 'border-slate-400 text-slate-800 focus:border-teal-600');
132
125
  if (!isSelect && !isLetter && 'size' in input) {
133
- input.size = isPhrase ? 18 : 10;
126
+ input.size = isPhrase ? 28 : 10;
134
127
  }
135
128
  // letter inline box — host FE may not scan Tailwind h-7/w-7
136
129
  if (isLetter) {
@@ -174,7 +167,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
174
167
  }, [processedHtml, isReviewMode]);
175
168
  return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-gray-800", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-gray-200", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-48 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: cn('structured-form-content rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 text-sm leading-relaxed text-gray-800', '[&_.structured-blank-wrapper]:inline-flex [&_.structured-blank-wrapper]:align-baseline [&_.structured-blank-wrapper]:items-baseline [&_.structured-blank-hint]:max-w-[180px]',
176
169
  // Conversation: speaker column hug name (not equal 50/50 split)
177
- '[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", val] }, key))) })] })] }))] }));
170
+ '[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] })] }))] }));
178
171
  });
179
172
  export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, }) {
180
173
  const answersRef = useRef(answers);
@@ -11,7 +11,7 @@ export interface TemplateSection {
11
11
  viewMode?: string;
12
12
  wordBankConfig?: string[];
13
13
  }
14
- export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
14
+ export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
15
15
  export type WfsfWordBankEntry = {
16
16
  options: string[];
17
17
  } | {
@@ -21,7 +21,7 @@ export type WfsfWordBankEntry = {
21
21
  export interface WordFillStructuredFormQuestion {
22
22
  id: string;
23
23
  content: string;
24
- answers: Record<string, string>;
24
+ answers: Record<string, string | string[]>;
25
25
  title?: string;
26
26
  instruction?: string;
27
27
  caseSensitive?: boolean;
@@ -48,6 +48,7 @@ export interface ExamTakingPart {
48
48
  wordBankConfig?: string[];
49
49
  startIndex?: number;
50
50
  endIndex?: number;
51
+ suggestedAnswers?: string[];
51
52
  }
52
53
  export declare function unwrap<T>(value: ApiData<T> | T): T;
53
54
  export declare function unwrapAttempt(value: ExamTakingAttempt | {
@@ -28,6 +28,7 @@ export function groupQuestions(result) {
28
28
  viewMode: typeof template?.group?.viewMode === 'string' ? template.group.viewMode : undefined,
29
29
  startIndex: template?.startIndex,
30
30
  endIndex: template?.endIndex,
31
+ suggestedAnswers: template?.suggestedAnswers,
31
32
  questions: questions.sort((a, b) => a.question_number - b.question_number),
32
33
  };
33
34
  });
@@ -117,6 +118,8 @@ export function transformWordFillStructuredForm(questions) {
117
118
  return 'letter';
118
119
  if (v === 'phrase' || v === 'sentence')
119
120
  return 'phrase';
121
+ if (v === 'translate')
122
+ return 'translate';
120
123
  return 'word';
121
124
  };
122
125
  const normalizeWordBank = (raw) => {
@@ -63,6 +63,7 @@ export interface QuestionPart {
63
63
  /** Zero-based exam-order start index of this part/section (for question numbering). */
64
64
  startIndex?: number;
65
65
  endIndex?: number;
66
+ suggestedAnswers?: string[];
66
67
  }
67
68
  /** Nested template group payload ({ items, config, type }) from the API template. */
68
69
  export interface TemplateGroupConfig {
@@ -108,6 +109,7 @@ export interface TemplatePart {
108
109
  category?: string;
109
110
  /** Max points for this part from exams.template.parts */
110
111
  points?: number;
112
+ suggestedAnswers?: string[];
111
113
  }
112
114
  /**
113
115
  * Group questions by part_id AND questionGroup.id
@@ -122,7 +124,9 @@ export interface TemplatePart {
122
124
  * @param templateParts - Optional template parts with name/instructions metadata
123
125
  */
124
126
  export declare function groupQuestionsByPart(questions: ApiQuestion[], templateParts?: TemplatePart[]): QuestionPart[];
125
- export declare function transformFillInBlank(questions: ApiQuestion[]): {
127
+ export declare function transformFillInBlank(questions: ApiQuestion[], options?: {
128
+ suggestedAnswers?: string[];
129
+ }): {
126
130
  questions: FillBlankQuestion[];
127
131
  title: string;
128
132
  imageUrl?: string;
@@ -134,6 +138,10 @@ export declare function transformFillInBlank(questions: ApiQuestion[]): {
134
138
  groupContent?: string;
135
139
  groupTitle?: string;
136
140
  groupImageUrl?: string;
141
+ imageLabels?: Array<{
142
+ imageUrl: string;
143
+ label: string;
144
+ }>;
137
145
  };
138
146
  export declare function transformWriteCorrectVerbForm(questions: ApiQuestion[]): {
139
147
  questions: FillBlankQuestion[];
@@ -387,7 +395,7 @@ export declare function transformWriteSentences(questions: ApiQuestion[]): {
387
395
  title?: string;
388
396
  groupContent?: string;
389
397
  };
390
- export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
398
+ export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
391
399
  export type WfsfWordBankEntry = {
392
400
  options: string[];
393
401
  } | {
@@ -400,7 +408,7 @@ export interface WordFillStructuredFormQuestion {
400
408
  title: string;
401
409
  imageUrl?: string;
402
410
  content: string;
403
- answers: Record<string, string>;
411
+ answers: Record<string, string | string[]>;
404
412
  caseSensitive: boolean;
405
413
  /** letter = square boxes; word / phrase = underline width */
406
414
  blankStyle: WfsfBlankStyle;
@@ -41,7 +41,9 @@ export function groupQuestionsByPart(questions, templateParts) {
41
41
  // a single screen with the passage on the left and ALL questions on the
42
42
  // right — so keep it whole and pass the sections through for the renderer.
43
43
  const partLevelGroup = templatePart?.group;
44
- const hasDocumentGroup = Boolean(templatePart?.isGroup) && partLevelGroup?.type === 'DOCUMENT';
44
+ const sharedGroupType = String(partLevelGroup?.type || '').toUpperCase();
45
+ const hasDocumentGroup = Boolean(templatePart?.isGroup) &&
46
+ (sharedGroupType === 'DOCUMENT' || sharedGroupType === 'IMAGE_LABEL' || sharedGroupType === 'ARTICLES');
45
47
  // Mixed multi-section part (e.g. Listening SECTION 2: CHOOSE + MATCH +
46
48
  // WORD_FILL): keep it whole and pass the sections through so the grouped
47
49
  // renderer can stack each sub-section on one screen. Without this the part
@@ -68,6 +70,7 @@ export function groupQuestionsByPart(questions, templateParts) {
68
70
  sections: templatePart?.sections,
69
71
  startIndex: templatePart?.startIndex,
70
72
  endIndex: templatePart?.endIndex,
73
+ suggestedAnswers: templatePart?.suggestedAnswers,
71
74
  });
72
75
  continue;
73
76
  }
@@ -96,6 +99,7 @@ export function groupQuestionsByPart(questions, templateParts) {
96
99
  group: templatePart?.group,
97
100
  startIndex: templatePart?.startIndex,
98
101
  endIndex: templatePart?.endIndex,
102
+ suggestedAnswers: templatePart?.suggestedAnswers,
99
103
  });
100
104
  continue;
101
105
  }
@@ -138,6 +142,7 @@ export function groupQuestionsByPart(questions, templateParts) {
138
142
  group: section.group,
139
143
  startIndex: section.startIndex,
140
144
  endIndex: section.endIndex,
145
+ suggestedAnswers: section.suggestedAnswers ?? templatePart?.suggestedAnswers,
141
146
  });
142
147
  }
143
148
  }
@@ -150,7 +155,42 @@ export function groupQuestionsByPart(questions, templateParts) {
150
155
  return (a.questions[0]?.question_number || 0) - (b.questions[0]?.question_number || 0);
151
156
  });
152
157
  }
153
- export function transformFillInBlank(questions) {
158
+ // ============================================
159
+ // FILL_IN_BLANK Transformer
160
+ // ============================================
161
+ function asImageLabelItems(value) {
162
+ if (!Array.isArray(value))
163
+ return undefined;
164
+ const items = value
165
+ .map((item) => {
166
+ if (!item || typeof item !== 'object')
167
+ return null;
168
+ const imageUrl = String(item.imageUrl ?? '').trim();
169
+ const label = String(item.label ?? '').trim();
170
+ if (!imageUrl && !label)
171
+ return null;
172
+ return { imageUrl, label };
173
+ })
174
+ .filter((item) => item !== null);
175
+ return items.length > 0 ? items : undefined;
176
+ }
177
+ function collectFillInBlankImageLabels(questions, suggestedAnswers) {
178
+ for (const question of questions) {
179
+ const payload = question.questionGroup?.payload ||
180
+ question.question_group?.payload;
181
+ const fromGroup = asImageLabelItems(payload?.imageLabels);
182
+ if (fromGroup)
183
+ return fromGroup;
184
+ const fromContent = asImageLabelItems(question.content?.imageLabels);
185
+ if (fromContent)
186
+ return fromContent;
187
+ }
188
+ if (suggestedAnswers?.length) {
189
+ return suggestedAnswers.map((label) => ({ imageUrl: '', label: String(label) }));
190
+ }
191
+ return undefined;
192
+ }
193
+ export function transformFillInBlank(questions, options) {
154
194
  const firstQuestion = questions[0];
155
195
  const content = firstQuestion?.content;
156
196
  const groupPayload = firstQuestion?.questionGroup?.payload;
@@ -187,6 +227,7 @@ export function transformFillInBlank(questions) {
187
227
  groupContent: groupPayload?.content || groupPayload?.groupContent,
188
228
  groupTitle: groupPayload?.title,
189
229
  groupImageUrl: groupPayload?.mainImageUrl || groupPayload?.imageUrl,
230
+ imageLabels: collectFillInBlankImageLabels(questions, options?.suggestedAnswers),
190
231
  };
191
232
  }
192
233
  export function transformWriteCorrectVerbForm(questions) {
@@ -1168,6 +1209,8 @@ function normalizeWfsfBlankStyle(raw) {
1168
1209
  return 'letter';
1169
1210
  if (v === 'phrase' || v === 'sentence')
1170
1211
  return 'phrase';
1212
+ if (v === 'translate')
1213
+ return 'translate';
1171
1214
  return 'word';
1172
1215
  }
1173
1216
  function normalizeWfsfWordBank(raw) {
@@ -1,4 +1,4 @@
1
- import type { QuestionGroupConfig, BasicGroupData, ArticleData } from '../types/question-group.type';
1
+ import type { QuestionGroupConfig, BasicGroupData, ArticleData, ImageLabelData } from '../types/question-group.type';
2
2
  /**
3
3
  * Derives which group number a question at `questionIndexInPart` belongs to,
4
4
  * given the part's group configuration array.
@@ -23,6 +23,8 @@ export interface UseBasicQuestionGroupOptions {
23
23
  initialData?: BasicGroupData;
24
24
  /** Number of articles for ARTICLES group type (creates empty articles array when > 0) */
25
25
  articleCount?: number;
26
+ /** Number of image+label items for IMAGE_LABEL group type */
27
+ imageLabelCount?: number;
26
28
  }
27
29
  export interface UseBasicQuestionGroupReturn {
28
30
  /** Is this question part of a group (i.e. groups array is non-empty)? */
@@ -36,11 +38,13 @@ export interface UseBasicQuestionGroupReturn {
36
38
  groupContent: string;
37
39
  imageUrl: string;
38
40
  articles: ArticleData[];
41
+ imageLabels: ImageLabelData[];
39
42
  setGroupTitle: (value: string) => void;
40
43
  setGroupSubtitle: (value: string) => void;
41
44
  setGroupContent: (value: string) => void;
42
45
  setImageUrl: (value: string) => void;
43
46
  setArticles: (articles: ArticleData[]) => void;
47
+ setImageLabels: (imageLabels: ImageLabelData[]) => void;
44
48
  }
45
49
  /**
46
50
  * `useBasicQuestionGroup`
@@ -53,4 +57,4 @@ export interface UseBasicQuestionGroupReturn {
53
57
  * the same group), falls back to `initialData`, and writes every change back to
54
58
  * the store so sibling questions instantly see the updated values.
55
59
  */
56
- export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
60
+ export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, imageLabelCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
@@ -54,7 +54,7 @@ export function getIsFirstInGroup(questionIndexInPart, groups) {
54
54
  * the same group), falls back to `initialData`, and writes every change back to
55
55
  * the store so sibling questions instantly see the updated values.
56
56
  */
57
- export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, }) {
57
+ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, imageLabelCount = 0, }) {
58
58
  const setSharedPassageData = useExamQuestionStore((s) => s.setSharedPassageData);
59
59
  const getSharedPassageData = useExamQuestionStore((s) => s.getSharedPassageData);
60
60
  const isGrouped = Boolean(groups && groups.length > 0);
@@ -67,6 +67,9 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
67
67
  const defaultArticles = articleCount > 0
68
68
  ? Array.from({ length: articleCount }, () => ({ name: '', content: '', imageUrl: '' }))
69
69
  : [];
70
+ const defaultImageLabels = imageLabelCount > 0
71
+ ? Array.from({ length: imageLabelCount }, () => ({ imageUrl: '', label: '' }))
72
+ : [];
70
73
  const resolveInitial = () => {
71
74
  if (isGrouped && partId) {
72
75
  const stored = getSharedPassageData(partId, currentGroupNumber);
@@ -77,6 +80,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
77
80
  content: stored.groupContent ?? stored.passage ?? initialData?.content ?? '',
78
81
  imageUrl: stored.imageUrl ?? initialData?.imageUrl ?? '',
79
82
  articles: stored.articles ?? initialData?.articles ?? defaultArticles,
83
+ imageLabels: stored.imageLabels ?? initialData?.imageLabels ?? defaultImageLabels,
80
84
  };
81
85
  }
82
86
  }
@@ -86,6 +90,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
86
90
  content: initialData?.content ?? '',
87
91
  imageUrl: initialData?.imageUrl ?? '',
88
92
  articles: initialData?.articles ?? defaultArticles,
93
+ imageLabels: initialData?.imageLabels ?? defaultImageLabels,
89
94
  };
90
95
  };
91
96
  const initial = resolveInitial();
@@ -94,6 +99,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
94
99
  const [groupContent, setGroupContentState] = useState(initial.content ?? '');
95
100
  const [imageUrl, setImageUrlState] = useState(initial.imageUrl ?? '');
96
101
  const [articles, setArticlesState] = useState(initial.articles ?? defaultArticles);
102
+ const [imageLabels, setImageLabelsState] = useState(initial.imageLabels ?? defaultImageLabels);
97
103
  // Track the last rendered group number so we can re-sync when navigating
98
104
  const prevGroupNumberRef = useRef(currentGroupNumber);
99
105
  // Track whether we have already seeded from initialData for the current group
@@ -121,7 +127,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
121
127
  const newContent = initialData.content || null;
122
128
  const newImage = initialData.imageUrl || null;
123
129
  const newArticles = initialData.articles || null;
124
- if (newTitle === null && newSubtitle === null && newContent === null && newImage === null && newArticles === null)
130
+ const newImageLabels = initialData.imageLabels || null;
131
+ if (newTitle === null && newSubtitle === null && newContent === null && newImage === null && newArticles === null && newImageLabels === null)
125
132
  return;
126
133
  if (newTitle !== null)
127
134
  setGroupTitleState(newTitle);
@@ -133,6 +140,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
133
140
  setImageUrlState(newImage);
134
141
  if (newArticles !== null)
135
142
  setArticlesState(newArticles);
143
+ if (newImageLabels !== null)
144
+ setImageLabelsState(newImageLabels);
136
145
  setSharedPassageData(partId, currentGroupNumber, {
137
146
  passageTitle: newTitle ?? '',
138
147
  passageSubtitle: newSubtitle ?? '',
@@ -140,6 +149,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
140
149
  passage: newContent ?? '',
141
150
  imageUrl: newImage ?? '',
142
151
  articles: newArticles ?? defaultArticles,
152
+ imageLabels: newImageLabels ?? defaultImageLabels,
143
153
  });
144
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
145
155
  }, [initialData, partId, currentGroupNumber, isGrouped]);
@@ -153,12 +163,13 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
153
163
  if (!isGrouped || !partId)
154
164
  return;
155
165
  const stored = getSharedPassageData(partId, currentGroupNumber);
156
- if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles)) {
166
+ if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles || stored.imageLabels)) {
157
167
  setGroupTitleState(stored.passageTitle ?? '');
158
168
  setGroupSubtitleState(stored.passageSubtitle ?? '');
159
169
  setGroupContentState(stored.groupContent ?? stored.passage ?? '');
160
170
  setImageUrlState(stored.imageUrl ?? '');
161
171
  setArticlesState(stored.articles ?? defaultArticles);
172
+ setImageLabelsState(stored.imageLabels ?? defaultImageLabels);
162
173
  }
163
174
  else {
164
175
  setGroupTitleState(initialData?.title ?? '');
@@ -166,22 +177,26 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
166
177
  setGroupContentState(initialData?.content ?? '');
167
178
  setImageUrlState(initialData?.imageUrl ?? '');
168
179
  setArticlesState(initialData?.articles ?? defaultArticles);
180
+ setImageLabelsState(initialData?.imageLabels ?? defaultImageLabels);
169
181
  }
170
182
  // eslint-disable-next-line react-hooks/exhaustive-deps
171
183
  }, [currentGroupNumber, partId, isGrouped]);
172
184
  // ------------------------------------------------------------------
173
185
  // Helpers: write state + persist to store
174
186
  // ------------------------------------------------------------------
175
- const persistToStore = (title, subtitle, content, image, arts) => {
187
+ const persistToStore = (title, subtitle, content, image, arts, labels) => {
176
188
  if (!isGrouped || !partId)
177
189
  return;
190
+ const existing = getSharedPassageData(partId, currentGroupNumber);
178
191
  setSharedPassageData(partId, currentGroupNumber, {
192
+ ...existing,
179
193
  passageTitle: title,
180
194
  passageSubtitle: subtitle,
181
195
  groupContent: content,
182
196
  passage: content,
183
197
  imageUrl: image,
184
198
  articles: arts ?? articles,
199
+ imageLabels: labels ?? imageLabels,
185
200
  });
186
201
  };
187
202
  const setGroupTitle = (value) => {
@@ -202,7 +217,11 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
202
217
  };
203
218
  const setArticles = (value) => {
204
219
  setArticlesState(value);
205
- persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value);
220
+ persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value, imageLabels);
221
+ };
222
+ const setImageLabels = (value) => {
223
+ setImageLabelsState(value);
224
+ persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, articles, value);
206
225
  };
207
226
  return {
208
227
  isGrouped,
@@ -213,10 +232,12 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
213
232
  groupContent,
214
233
  imageUrl,
215
234
  articles,
235
+ imageLabels,
216
236
  setGroupTitle,
217
237
  setGroupSubtitle,
218
238
  setGroupContent,
219
239
  setImageUrl,
220
240
  setArticles,
241
+ setImageLabels,
221
242
  };
222
243
  }
@@ -40,6 +40,11 @@ export interface FillInBlankData {
40
40
  groupImageUrl?: string;
41
41
  groupNumber?: number;
42
42
  groups?: FillInBlankGroup[];
43
+ /** Picture + label bank when groupType === "IMAGE_LABEL" */
44
+ imageLabels?: Array<{
45
+ imageUrl: string;
46
+ label: string;
47
+ }>;
43
48
  blanks?: BlankItem[];
44
49
  }
45
50
  export interface FillInBlankCreatorProps {
@@ -55,6 +60,10 @@ export interface FillInBlankCreatorProps {
55
60
  partId?: string;
56
61
  /** Template questionConfig — controls which fields are shown (e.g. "audio") */
57
62
  questionConfig?: string[];
63
+ /** Template group.type (e.g. IMAGE_LABEL) */
64
+ groupType?: string;
65
+ /** Template suggestedAnswers — prefill IMAGE_LABEL labels when empty */
66
+ suggestedAnswers?: string[];
58
67
  }
59
68
  export interface FillInBlankClientProps {
60
69
  questionData: FillInBlankData;
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Different question types may use different group shapes:
8
8
  * - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
9
+ * - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
9
10
  * - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
10
11
  */
11
12
  /** One entry in a part's group configuration array */
@@ -24,6 +25,14 @@ export interface ArticleData {
24
25
  /** Hình ảnh – S3 key hoặc URL */
25
26
  imageUrl: string;
26
27
  }
28
+ /** One item in an IMAGE_LABEL word bank (e.g. GET Primary vocabulary box) */
29
+ export interface ImageLabelData {
30
+ /** Hình ảnh – S3 key hoặc URL */
31
+ imageUrl: string;
32
+ /** Nhãn / từ hiển thị dưới ảnh */
33
+ label: string;
34
+ }
35
+ export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
27
36
  /**
28
37
  * Shared context fields for a group of ANSWER_THE_QUESTION,
29
38
  * CHOOSE_THE_CORRECT_ANSWER, or FILL_IN_BLANK questions.
@@ -41,4 +50,6 @@ export interface BasicGroupData {
41
50
  imageUrl?: string;
42
51
  /** Mảng bài viết – dùng cho groupType "ARTICLES" (optional) */
43
52
  articles?: ArticleData[];
53
+ /** Mảng ảnh + nhãn – dùng cho groupType "IMAGE_LABEL" (optional) */
54
+ imageLabels?: ImageLabelData[];
44
55
  }
@@ -6,6 +6,9 @@
6
6
  *
7
7
  * Different question types may use different group shapes:
8
8
  * - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
9
+ * - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
9
10
  * - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
10
11
  */
11
- export {};
12
+ export function hasImageLabels(items) {
13
+ return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
14
+ }
@@ -13,6 +13,13 @@ export interface SpontaneousQAData {
13
13
  /** Points per question */
14
14
  points?: number;
15
15
  }
16
+ export interface SpontaneousQAClientProps {
17
+ questionData: SpontaneousQAData & {
18
+ isBackup?: boolean;
19
+ expectedAnswers?: string[];
20
+ };
21
+ isReviewMode?: boolean;
22
+ }
16
23
  export interface SpontaneousQACreatorProps {
17
24
  initialData?: SpontaneousQAData;
18
25
  onSave?: (data: SpontaneousQAData) => void;