@tinyweb_dev/oe-exam-sdk 1.0.1 → 1.0.3

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 (36) hide show
  1. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +1 -1
  3. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +13 -20
  4. package/dist/components/exams/take/exam-taking.utils.d.ts +2 -2
  5. package/dist/components/exams/take/exam-taking.utils.js +2 -0
  6. package/dist/components/exams/take/utils/question-transformers.d.ts +2 -2
  7. package/dist/components/exams/take/utils/question-transformers.js +2 -0
  8. package/dist/components/questions/_shared/types/question-group.type.d.ts +0 -1
  9. package/dist/components/questions/_shared/types/question-group.type.js +1 -3
  10. package/dist/components/questions/_shared/types/question-group.utils.d.ts +2 -0
  11. package/dist/components/questions/_shared/types/question-group.utils.js +3 -0
  12. package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
  13. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +1 -1
  14. package/dist/components/questions/groups/ImageLabelWordBank.js +1 -1
  15. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +1 -1
  16. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +1 -1
  17. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
  18. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
  19. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
  20. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
  21. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +2 -10
  22. package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -1
  23. package/dist/components/questions/viewer/QuestionViewer.js +0 -67
  24. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +1 -1
  25. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +1 -1
  26. package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
  27. package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
  28. package/dist/shared/constants/routes.d.ts +2 -2
  29. package/dist/shared/constants/routes.js +3 -3
  30. package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
  31. package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
  32. package/package.json +1 -1
  33. package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
  34. package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
  35. package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
  36. package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
@@ -1,5 +1,5 @@
1
1
  import type { FillBlankQuestion } from '../../types';
2
- import { type ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
2
+ import type { ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
3
3
  interface MoversListenAndWriteRendererProps {
4
4
  partNumber: number;
5
5
  partName?: string;
@@ -7,7 +7,7 @@ import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-
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
9
  import { ImageLabelWordBank } from '../../../../questions/groups/ImageLabelWordBank';
10
- import { hasImageLabels } from '../../../../questions/_shared/types/question-group.type';
10
+ import { hasImageLabels } from '../../../../questions/_shared/types/question-group.utils';
11
11
  /** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
12
12
  function renderTextWithLineBreaks(text) {
13
13
  const lines = text.split('\n');
@@ -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
  /**
@@ -98,33 +91,33 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
98
91
  const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
99
92
  const hint = container.querySelector(`[data-blank-hint="${key}"]`);
100
93
  const userAnswer = initialAnswersRef.current[key] || '';
101
- const correctAnswer = question.answers[key] || '';
94
+ const correctAnswer = question.answers[key];
95
+ const correctLabel = formatWfsfCorrectLabel(correctAnswer);
102
96
  const hasUserAnswer = userAnswer.trim().length > 0;
103
97
  const isCorrect = hasUserAnswer
104
- && normalizeStructuredAnswer(userAnswer, question.caseSensitive)
105
- === normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
98
+ && isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
106
99
  // Restore saved answer value on mount
107
- const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctAnswer.trim().length > 0;
100
+ const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
108
101
  input.value = shouldReplaceInputWithReviewText
109
- ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctAnswer}`
102
+ ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctLabel}`
110
103
  : userAnswer;
111
104
  input.disabled = isReviewMode;
112
105
  const isSelect = input.tagName === 'SELECT';
113
106
  // Set classes from blankStyle (letter = square box)
114
107
  const blankStyle = question.blankStyle || 'word';
115
108
  const isLetter = blankStyle === 'letter';
116
- const isPhrase = blankStyle === 'phrase';
109
+ const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
117
110
  if (isLetter && !isSelect && 'maxLength' in input) {
118
- input.maxLength = Math.max(1, (correctAnswer || ' ').trim().length || 1);
111
+ const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
112
+ input.maxLength = Math.max(1, maxAltLen || 1);
119
113
  }
120
114
  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
121
115
  ? 'min-w-[5.5rem] max-w-[10rem] rounded border px-2 py-1 text-center'
122
116
  : isLetter
123
117
  ? 'h-7 w-7 rounded border-2 px-0 text-center uppercase'
124
118
  : isPhrase
125
- ? 'min-w-[9rem] max-w-[18rem] w-[14ch] rounded-t border-b-2 px-2 py-0.5 text-center'
126
- : // blankStyle=word short single-token field
127
- 'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
119
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5 text-center'
120
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
128
121
  ? isCorrect
129
122
  ? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
130
123
  : hasUserAnswer
@@ -132,7 +125,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
132
125
  : 'border-amber-500 bg-amber-50 text-amber-800 font-bold'
133
126
  : 'border-slate-400 text-slate-800 focus:border-teal-600');
134
127
  if (!isSelect && !isLetter && 'size' in input) {
135
- input.size = isPhrase ? 18 : 10;
128
+ input.size = isPhrase ? 28 : 10;
136
129
  }
137
130
  // letter inline box — host FE may not scan Tailwind h-7/w-7
138
131
  if (isLetter) {
@@ -176,7 +169,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
176
169
  }, [processedHtml, isReviewMode]);
177
170
  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]',
178
171
  // Conversation: speaker column hug name (not equal 50/50 split)
179
- '[&_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))) })] })] }))] }));
172
+ '[&_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))) })] })] }))] }));
180
173
  });
181
174
  export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, compactLayout = false, }) {
182
175
  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;
@@ -118,6 +118,8 @@ export function transformWordFillStructuredForm(questions) {
118
118
  return 'letter';
119
119
  if (v === 'phrase' || v === 'sentence')
120
120
  return 'phrase';
121
+ if (v === 'translate')
122
+ return 'translate';
121
123
  return 'word';
122
124
  };
123
125
  const normalizeWordBank = (raw) => {
@@ -395,7 +395,7 @@ export declare function transformWriteSentences(questions: ApiQuestion[]): {
395
395
  title?: string;
396
396
  groupContent?: string;
397
397
  };
398
- export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
398
+ export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
399
399
  export type WfsfWordBankEntry = {
400
400
  options: string[];
401
401
  } | {
@@ -408,7 +408,7 @@ export interface WordFillStructuredFormQuestion {
408
408
  title: string;
409
409
  imageUrl?: string;
410
410
  content: string;
411
- answers: Record<string, string>;
411
+ answers: Record<string, string | string[]>;
412
412
  caseSensitive: boolean;
413
413
  /** letter = square boxes; word / phrase = underline width */
414
414
  blankStyle: WfsfBlankStyle;
@@ -1209,6 +1209,8 @@ function normalizeWfsfBlankStyle(raw) {
1209
1209
  return 'letter';
1210
1210
  if (v === 'phrase' || v === 'sentence')
1211
1211
  return 'phrase';
1212
+ if (v === 'translate')
1213
+ return 'translate';
1212
1214
  return 'word';
1213
1215
  }
1214
1216
  function normalizeWfsfWordBank(raw) {
@@ -32,7 +32,6 @@ export interface ImageLabelData {
32
32
  /** Nhãn / từ hiển thị dưới ảnh */
33
33
  label: string;
34
34
  }
35
- export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
36
35
  /**
37
36
  * Shared context fields for a group of ANSWER_THE_QUESTION,
38
37
  * CHOOSE_THE_CORRECT_ANSWER, or FILL_IN_BLANK questions.
@@ -9,6 +9,4 @@
9
9
  * - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
10
10
  * - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
11
11
  */
12
- export function hasImageLabels(items) {
13
- return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
14
- }
12
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ImageLabelData } from './question-group.type';
2
+ export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
@@ -0,0 +1,3 @@
1
+ export function hasImageLabels(items) {
2
+ return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
3
+ }
@@ -22,7 +22,7 @@ export interface WordFillStructuredFormData {
22
22
  audioUrl?: string;
23
23
  audioUrls?: string[];
24
24
  content: string;
25
- answers: Record<string, string>;
25
+ answers: Record<string, string[]>;
26
26
  /**
27
27
  * letter = square 1-char box; word = single-word underline; phrase = multi-word; translate = LLM grading.
28
28
  * Default when omitted: word.
@@ -1,4 +1,4 @@
1
- import { type ImageLabelData } from '../_shared/types/question-group.type';
1
+ import type { ImageLabelData } from '../_shared/types/question-group.type';
2
2
  export interface ImageLabelWordBankProps {
3
3
  items?: ImageLabelData[];
4
4
  title?: string;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ResolvedImage } from '../../../components/common/ResolvedImage';
4
- import { hasImageLabels } from '../_shared/types/question-group.type';
4
+ import { hasImageLabels } from '../_shared/types/question-group.utils';
5
5
  /**
6
6
  * Horizontal picture + label bank shown to students (and creator preview).
7
7
  */
@@ -449,7 +449,7 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
449
449
  ? 'border-violet-300 bg-violet-50 text-violet-700'
450
450
  : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'} ${Boolean(multipleAnswers) ? 'cursor-not-allowed opacity-50' : ''}`, children: [_jsx(ListChecks, { className: "h-4 w-4" }), "Nhi\u1EC1u \u0111\u00E1p \u00E1n"] }), _jsxs("button", { type: "button", style: { paddingBlock: 'calc(var(--spacing) * 1)' }, onClick: () => setIsTipTapMode((enabled) => !enabled), title: isTipTapMode ? 'Chuyển về ô nhập thường' : 'Bật định dạng chữ (in đậm, danh sách, bảng)', className: `inline-flex items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${isTipTapMode
451
451
  ? 'border-indigo-300 bg-indigo-50 text-indigo-700'
452
- : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Pilcrow, { className: "h-4 w-4" }), "\u0110\u1ECBnh d\u1EA1ng"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(ChooseAnswerTextInput, { value: watchedQuestion, onChange: (html) => form.setValue('question', html, { shouldValidate: true }), isTipTapMode: isTipTapMode, placeholder: "Nh\u1EADp c\u00E2u h\u1ECFi..." }), form.hasError('question') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('question') }))] })), ((!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && !isImageInputVisible
452
+ : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Pilcrow, { className: "h-4 w-4" }), "\u0110\u1ECBnh d\u1EA1ng"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(ChooseAnswerTextInput, { value: watchedQuestion ?? '', onChange: (html) => form.setValue('question', html, { shouldValidate: true }), isTipTapMode: isTipTapMode, placeholder: "Nh\u1EADp c\u00E2u h\u1ECFi..." }), form.hasError('question') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('question') }))] })), ((!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && !isImageInputVisible
453
453
  || !isExplanationVisible) && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [(!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && !isImageInputVisible && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsImageInputVisible(true), className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(ImageIcon, { className: "h-4 w-4" }), watchedImageUrl ? 'Hiện hình ảnh' : 'Thêm hình ảnh'] })), !isExplanationVisible && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsExplanationVisible(true), className: "gap-2 border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800", children: [_jsx(BookOpen, { className: "h-4 w-4" }), watchedExplanation ? 'Hiện giải thích' : 'Thêm giải thích'] }))] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && isImageInputVisible && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-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(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsImageInputVisible(false), className: "h-8 text-gray-500 hover:text-gray-700", children: "\u1EA8n" })] }), _jsx(FileUpload, { id: "question-image", label: "", accept: "image/*", value: watchedImageUrl || '', onChange: (url) => {
454
454
  form.setValue('imageUrl', url, { shouldValidate: true });
455
455
  }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), form.hasError('imageUrl') && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: form.getFieldError('imageUrl') })), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), isExplanationVisible && (_jsxs("div", { className: "relative", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsExplanationVisible(false), className: "absolute right-3 top-3 z-10 h-8 text-gray-500 hover:text-gray-700", children: "\u1EA8n" }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { ...form.register('explanation'), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] resize-none border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200" }) })] })] })), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, index) => (_jsx("li", { children: error }, index))) })] })] })), form.formState.errors.options?.message && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.formState.errors.options.message }) }))] })] }), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('options')) && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-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-6 w-6 items-center justify-center rounded-md bg-indigo-100", children: _jsx(Lightbulb, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["C\u00E1c l\u1EF1a ch\u1ECDn ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", watchedOptions.length, " \u0111\u00E1p \u00E1n)"] })] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: watchedOptionType === 'image', disabled: true, className: watchedOptionType === 'image' ? '!bg-indigo-600 !opacity-100' : '!bg-indigo-600 !opacity-100', onCheckedChange: (checked) => {
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
4
4
  import { Input } from '../../../../components/ui/input';
5
5
  import { Check, X, PenLine, AlertTriangle, ClipboardList, Lightbulb, BookOpen, Hash, Volume2, } from 'lucide-react';
6
6
  import { ImageLabelWordBank } from '../../groups/ImageLabelWordBank';
7
- import { hasImageLabels } from '../../_shared/types/question-group.type';
7
+ import { hasImageLabels } from '../../_shared/types/question-group.utils';
8
8
  import { extractPlaceholderIndices, formatBlankAnswersDisplay, getPrimaryBlankAnswer, isBlankInputCorrect, normalizeBlankAnswers, normalizeFillInBlankAnswers, } from './answer-utils';
9
9
  import { parseFillBlankSegments } from '../../../../shared/lib/utils/fill-in-blank';
10
10
  import { cn } from '../../../../shared/lib/utils';
@@ -8,7 +8,7 @@ export type WfsfWordBankEditorProps = {
8
8
  optionsBank: WfsfOptionsWordBankEntry[];
9
9
  onOptionsBankChange: (next: WfsfOptionsWordBankEntry[]) => void;
10
10
  blankKeys: string[];
11
- answers: Record<string, string>;
12
- onAnswerChange: (key: string, value: string) => void;
11
+ answers: Record<string, string[]>;
12
+ onAnswerChange: (key: string, value: string[]) => void;
13
13
  };
14
14
  export declare function WfsfWordBankEditor({ mode, onModeChange, sharedBank, onSharedBankChange, optionsBank, onOptionsBankChange, blankKeys, answers, onAnswerChange, }: WfsfWordBankEditorProps): import("react").JSX.Element;
@@ -60,9 +60,10 @@ export function WfsfWordBankEditor({ mode, onModeChange, sharedBank, onSharedBan
60
60
  next[blankIndex] = { options };
61
61
  onOptionsBankChange(next);
62
62
  const key = blankKeys[blankIndex];
63
- const removed = entry.options[optionIndex];
64
- if (key && answers[key] === removed)
65
- onAnswerChange(key, '');
63
+ const removed = entry.options[optionIndex]?.trim();
64
+ if (key && removed && (answers[key] || []).includes(removed)) {
65
+ onAnswerChange(key, (answers[key] || []).filter((alt) => alt !== removed));
66
+ }
66
67
  };
67
68
  return (_jsxs("div", { className: "space-y-4", children: [_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-100/50", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-indigo-400 to-purple-400" }), _jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-indigo-100", children: _jsx(Library, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Word Bank / g\u1EE3i \u00FD \u0111\u00E1p \u00E1n" }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Ch\u1ECDn ch\u1EBF \u0111\u1ED9 hi\u1EC3n th\u1ECB cho h\u1ECDc sinh khi l\u00E0m b\u00E0i (WFSF plan 0024/0025)" })] })] }) }), _jsx(CardContent, { className: "space-y-3 px-4 pb-4", children: _jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-3", children: MODE_OPTIONS.map((opt) => {
68
69
  const active = mode === opt.id;
@@ -76,10 +77,19 @@ export function WfsfWordBankEditor({ mode, onModeChange, sharedBank, onSharedBan
76
77
  : 'bg-gradient-to-br from-purple-500 to-pink-600'}`, children: index + 1 }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Checkbox, { id: `wfsf-ex-${index}`, checked: !!item.isExample, onCheckedChange: (c) => toggleSharedExample(index, c === true) }), _jsx(Label, { htmlFor: `wfsf-ex-${index}`, className: "cursor-pointer text-xs text-gray-600", children: "Example" })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeSharedWord(index), className: "h-7 w-7 p-0 text-gray-400 hover:bg-red-50 hover:text-red-500", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] })] }), _jsx(Input, { value: item.word, onChange: (e) => updateSharedWord(index, e.target.value), placeholder: "Nh\u1EADp t\u1EEB...", className: item.isExample ? 'bg-amber-50' : 'border-gray-200' })] }, index))) }))] })] })), mode === 'WITH_OPTIONS' && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-100/50", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-teal-400 to-cyan-400" }), _jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-teal-100", children: _jsx(ListChecks, { className: "h-3.5 w-3.5 text-teal-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["L\u1EF1a ch\u1ECDn theo ch\u1ED7 tr\u1ED1ng (", blankKeys.length, ")"] })] }) }), _jsx(CardContent, { className: "space-y-4 px-4 pb-4", children: blankKeys.length === 0 ? (_jsxs("p", { className: "rounded-lg border-2 border-dashed border-gray-200 p-6 text-center text-sm text-gray-500", children: ["Ch\u01B0a c\u00F3 ch\u1ED7 tr\u1ED1ng. Th\u00EAm ", '{0}', ", ", '{1}', "\u2026 trong n\u1ED9i dung tr\u01B0\u1EDBc."] })) : (blankKeys.map((key, blankIndex) => {
77
78
  const entry = optionsBank[blankIndex] ?? null;
78
79
  const isFree = entry == null;
79
- const selected = answers[key] || '';
80
+ const selected = (answers[key] || []).filter(Boolean);
80
81
  return (_jsxs("div", { className: `rounded-xl border-2 p-4 ${isFree
81
82
  ? 'border-dashed border-gray-200 bg-gray-50/40'
82
- : 'border-gray-200 bg-white'}`, children: [_jsxs("div", { className: "mb-3 flex flex-wrap items-center justify-between gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-cyan-600 text-sm font-bold text-white", children: key }), _jsxs("span", { className: "text-sm font-semibold text-gray-700", children: ["Ch\u1ED7 tr\u1ED1ng ", _jsx("code", { className: "text-teal-700", children: `{${key}}` })] }), selected && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700", children: [_jsx(CheckCircle2, { className: "h-3 w-3" }), selected] }))] }), _jsxs("label", { className: "flex items-center gap-2 text-xs text-gray-600", children: [_jsx(Checkbox, { checked: isFree, onCheckedChange: (c) => setBlankFreeType(blankIndex, c === true) }), "Free-type (kh\u00F4ng options)"] })] }), !isFree && entry && (_jsxs("div", { className: "space-y-2", children: [entry.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", title: "Ch\u1ECDn l\u00E0m \u0111\u00E1p \u00E1n \u0111\u00FAng", onClick: () => option.trim() && onAnswerChange(key, option.trim()), className: `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${selected === option.trim() && option.trim()
83
+ : 'border-gray-200 bg-white'}`, children: [_jsxs("div", { className: "mb-3 flex flex-wrap items-center justify-between gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-cyan-600 text-sm font-bold text-white", children: key }), _jsxs("span", { className: "text-sm font-semibold text-gray-700", children: ["Ch\u1ED7 tr\u1ED1ng ", _jsx("code", { className: "text-teal-700", children: `{${key}}` })] }), selected.length > 0 && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700", children: [_jsx(CheckCircle2, { className: "h-3 w-3" }), selected.join(' / ')] }))] }), _jsxs("label", { className: "flex items-center gap-2 text-xs text-gray-600", children: [_jsx(Checkbox, { checked: isFree, onCheckedChange: (c) => setBlankFreeType(blankIndex, c === true) }), "Free-type (kh\u00F4ng options)"] })] }), !isFree && entry && (_jsxs("div", { className: "space-y-2", children: [entry.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", title: "Ch\u1ECDn / b\u1ECF \u0111\u00E1p \u00E1n \u0111\u00FAng", onClick: () => {
84
+ const opt = option.trim();
85
+ if (!opt)
86
+ return;
87
+ const current = answers[key] || [];
88
+ const next = current.includes(opt)
89
+ ? current.filter((alt) => alt !== opt)
90
+ : [...current, opt];
91
+ onAnswerChange(key, next);
92
+ }, className: `flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${selected.includes(option.trim()) && option.trim()
83
93
  ? 'bg-green-500 text-white'
84
94
  : 'bg-gray-100 text-gray-500 hover:bg-green-100 hover:text-green-600'}`, children: String.fromCharCode(65 + optionIndex) }), _jsx(Input, { value: option, onChange: (e) => updateOption(blankIndex, optionIndex, e.target.value), placeholder: `Lựa chọn ${String.fromCharCode(65 + optionIndex)}`, className: "flex-1 border-gray-200 text-sm" }), entry.options.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeOption(blankIndex, optionIndex), className: "h-8 w-8 p-0 text-red-400 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] }, optionIndex))), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => addOption(blankIndex), className: "mt-1 gap-1 text-xs text-teal-600 hover:bg-teal-50", children: [_jsx(Plus, { className: "h-3 w-3" }), "Th\u00EAm l\u1EF1a ch\u1ECDn"] })] }))] }, key));
85
95
  })) })] }))] }));
@@ -5,12 +5,15 @@ import { Check, X, FileText, Library } from 'lucide-react';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  import { cn } from '../../../../shared/lib/utils';
7
7
  import { detectWfsfBankMode, isOptionsWordEntry, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
8
+ import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, primaryWfsfAnswers, } from '../../../../shared/lib/utils/wfsf-blank-match';
8
9
  function normalizeWfsfBlankStyle(raw) {
9
10
  const v = String(raw || '').toLowerCase();
10
11
  if (v === 'letter' || v === 'box')
11
12
  return 'letter';
12
13
  if (v === 'phrase' || v === 'sentence')
13
14
  return 'phrase';
15
+ if (v === 'translate')
16
+ return 'translate';
14
17
  return 'word';
15
18
  }
16
19
  function parseStyleAttribute(styleValue) {
@@ -59,7 +62,10 @@ function ClientAudioItem({ url }) {
59
62
  }
60
63
  const DEFAULT_WFSF_HEADER = 'Điền từ vào biểu mẫu có cấu trúc';
61
64
  export function WordFillStructuredFormClient({ questionData, isReviewMode = false, userAnswer, onAnswerChange, autoFillCorrectAnswers = false, instruction, questionNumber, explanation, }) {
62
- const { title = '', content = '', answers = {}, caseSensitive = false, viewMode, wordBank = [], instruction: dataInstruction, } = questionData;
65
+ const { title = '', content = '', answers = {}, caseSensitive = false, blankStyle: rawBlankStyle, viewMode, wordBank = [], instruction: dataInstruction, } = questionData;
66
+ const blankStyle = normalizeWfsfBlankStyle(rawBlankStyle);
67
+ const isLetterBlank = blankStyle === 'letter';
68
+ const isPhraseBlank = blankStyle === 'phrase' || blankStyle === 'translate';
63
69
  const allImages = useMemo(() => {
64
70
  if (Array.isArray(questionData.imageUrls) && questionData.imageUrls.length > 0) {
65
71
  return questionData.imageUrls.filter(Boolean);
@@ -80,7 +86,7 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
80
86
  if (userAnswer)
81
87
  return { ...userAnswer };
82
88
  if (autoFillCorrectAnswers)
83
- return { ...answers };
89
+ return primaryWfsfAnswers(answers);
84
90
  return {};
85
91
  });
86
92
  const [expandedBlank, setExpandedBlank] = useState(null);
@@ -127,11 +133,7 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
127
133
  });
128
134
  }, [content]);
129
135
  const allFilled = expectedBlankKeys.every(k => filledAnswers[k]?.trim());
130
- const isAllCorrect = isReviewMode && expectedBlankKeys.every(k => {
131
- const userVal = (filledAnswers[k] || '').trim();
132
- const correctVal = (answers[k] || '').trim();
133
- return caseSensitive ? userVal === correctVal : userVal.toLowerCase() === correctVal.toLowerCase();
134
- });
136
+ const isAllCorrect = isReviewMode && expectedBlankKeys.every((k) => isWfsfBlankCorrect(filledAnswers[k] || '', answers[k], caseSensitive));
135
137
  // Header = page rubric. Pearson preview maps draft.instruction → explanation.
136
138
  const rawExplanation = String(explanation || questionData.explanation || '').trim();
137
139
  const headerInstruction = String(instruction || dataInstruction || rawExplanation || '').trim();
@@ -153,11 +155,8 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
153
155
  }, [isReviewMode, onAnswerChange]);
154
156
  const renderBlank = useCallback((key, reactKey) => {
155
157
  const filled = filledAnswers[key] || '';
156
- const correctAnswer = answers[key] || '';
157
- const normalizedFilled = caseSensitive ? filled.trim() : filled.trim().toLowerCase();
158
- const normalizedCorrect = caseSensitive ? correctAnswer.trim() : correctAnswer.trim().toLowerCase();
159
- const isCorrectFill = isReviewMode && normalizedFilled === normalizedCorrect;
160
- const isWrongFill = isReviewMode && !!filled && normalizedFilled !== normalizedCorrect;
158
+ const isCorrectFill = isReviewMode && isWfsfBlankCorrect(filled, answers[key], caseSensitive);
159
+ const isWrongFill = isReviewMode && !!filled && !isCorrectFill;
161
160
  const blankOptions = optionsByKey.get(key);
162
161
  if (bankMode === 'WITH_OPTIONS' && blankOptions && blankOptions.length >= 2) {
163
162
  const isExpanded = expandedBlank === key;
@@ -173,13 +172,36 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
173
172
  ? 'border-teal-500 bg-teal-100 text-teal-800'
174
173
  : 'border-gray-200 bg-white text-gray-700 hover:border-teal-300 hover:bg-teal-50'), children: opt }, opt))) }))] }, reactKey));
175
174
  }
176
- return (_jsxs("span", { className: "relative mx-1 inline-flex items-baseline align-baseline", children: [_jsx("span", { className: "absolute -top-4 left-1 text-[10px] font-semibold text-gray-400", children: key }), _jsx("input", { type: "text", value: filled, onChange: (e) => handleInputChange(key, e.target.value), disabled: isReviewMode, placeholder: "", className: cn('inline-block min-w-[90px] max-w-[200px] rounded-t border-b-2 bg-slate-50/40 px-2 py-0.5 text-center text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isReviewMode
175
+ return (_jsxs("span", { className: "relative mx-1 inline-flex items-baseline align-baseline", children: [_jsx("span", { className: "absolute -top-4 left-1 text-[10px] font-semibold text-gray-400", children: key }), _jsx("input", { type: "text", value: filled, onChange: (e) => handleInputChange(key, e.target.value), disabled: isReviewMode, placeholder: "", maxLength: isLetterBlank
176
+ ? Math.max(1, expandWfsfCorrectAlts(answers[key]).reduce((max, alt) => Math.max(max, alt.trim().length), 0) || 1)
177
+ : undefined, size: isLetterBlank ? undefined : isPhraseBlank ? 28 : 10, className: cn('inline-block bg-slate-50/40 text-center text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isLetterBlank
178
+ ? 'h-7 w-7 rounded border-2 px-0 uppercase'
179
+ : isPhraseBlank
180
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5'
181
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5', isReviewMode
177
182
  ? isCorrectFill
178
183
  ? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
179
184
  : isWrongFill
180
185
  ? 'border-rose-500 bg-rose-50 text-rose-800 font-bold'
181
186
  : 'border-amber-500 bg-amber-50 text-amber-800'
182
- : 'border-slate-400 text-slate-800 focus:border-teal-600') }), isReviewMode && filled && (isCorrectFill
187
+ : 'border-slate-400 text-slate-800 focus:border-teal-600'), style: isLetterBlank
188
+ ? {
189
+ display: 'inline-block',
190
+ width: '1.75rem',
191
+ height: '1.75rem',
192
+ minWidth: '1.75rem',
193
+ maxWidth: '1.75rem',
194
+ padding: 0,
195
+ margin: '0 2px',
196
+ borderWidth: 2,
197
+ borderStyle: 'solid',
198
+ borderRadius: 4,
199
+ textAlign: 'center',
200
+ fontWeight: 700,
201
+ textTransform: 'uppercase',
202
+ boxSizing: 'border-box',
203
+ }
204
+ : undefined }), isReviewMode && filled && (isCorrectFill
183
205
  ? _jsx(Check, { className: "ml-0.5 h-3.5 w-3.5 text-green-600" })
184
206
  : _jsx(X, { className: "ml-0.5 h-3.5 w-3.5 text-red-600" }))] }, reactKey));
185
207
  }, [
@@ -190,6 +212,8 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
190
212
  filledAnswers,
191
213
  handleInputChange,
192
214
  handleSelectOption,
215
+ isLetterBlank,
216
+ isPhraseBlank,
193
217
  isReviewMode,
194
218
  optionsByKey,
195
219
  ]);
@@ -260,5 +284,5 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
260
284
  : used
261
285
  ? 'border-purple-200 bg-white/60 text-purple-400 line-through'
262
286
  : 'border-purple-300 bg-white text-purple-800'), children: [item.word, item.isExample ? ' (ex)' : ''] }, `${item.word}-${index}`));
263
- }) })] })), _jsx("div", { className: "structured-form-content space-y-2 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5 text-base leading-loose text-gray-800", children: renderedContent }), isReviewMode && !isAllCorrect && (_jsxs("div", { className: "rounded-lg border border-green-200 bg-green-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-green-600", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: expectedBlankKeys.map((key) => (_jsxs("span", { className: "rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-sm font-semibold text-green-800", children: [`{${key}}`, ": ", answers[key]] }, key))) })] })), isReviewMode && displayExplanation && (_jsxs("div", { className: "rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-blue-600", children: "Gi\u1EA3i th\u00EDch" }), _jsx("p", { className: "mt-1 text-sm text-blue-800", children: displayExplanation })] }))] })] }) }));
287
+ }) })] })), _jsx("div", { className: "structured-form-content space-y-2 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5 text-base leading-loose text-gray-800", children: renderedContent }), isReviewMode && !isAllCorrect && (_jsxs("div", { className: "rounded-lg border border-green-200 bg-green-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-green-600", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: expectedBlankKeys.map((key) => (_jsxs("span", { className: "rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-sm font-semibold text-green-800", children: [`{${key}}`, ": ", formatWfsfCorrectLabel(answers[key])] }, key))) })] })), isReviewMode && displayExplanation && (_jsxs("div", { className: "rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-blue-600", children: "Gi\u1EA3i th\u00EDch" }), _jsx("p", { className: "mt-1 text-sm text-blue-800", children: displayExplanation })] }))] })] }) }));
264
288
  }
@@ -20,6 +20,7 @@ import { ImagePreview } from '../../../../components/ui/image-preview';
20
20
  import { WordFillStructuredFormClient } from './WordFillStructuredFormClient';
21
21
  import { WfsfWordBankEditor } from './WfsfWordBankEditor';
22
22
  import { useDebouncedCallback, usePresignedFileUrl } from '../../../../shared/lib/hooks';
23
+ import { expandWfsfCorrectAlts, hasWfsfBlankAnswer, normalizeWfsfAnswersMap, primaryWfsfAnswers, } from '../../../../shared/lib/utils/wfsf-blank-match';
23
24
  import { BasicQuestionGroupCard } from '../../groups/BasicQuestionGroupCard';
24
25
  import { DocumentGroupCard } from '../../groups/DocumentGroupCard';
25
26
  import { useBasicQuestionGroup, getGroupNumberForIndex } from '../../_shared/hooks/useBasicQuestionGroup';
@@ -173,7 +174,7 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
173
174
  // ── TipTap ─────────────────────────────────────────────────────────────────
174
175
  const initialContent = initialData?.content || '<p></p>';
175
176
  const [content, setContent] = useState(initialContent);
176
- const [answers, setAnswers] = useState(initialData?.answers || {});
177
+ const [answers, setAnswers] = useState(() => normalizeWfsfAnswersMap(initialData?.answers || {}));
177
178
  const previousPayloadRef = useRef('');
178
179
  const previousInitialDataRef = useRef(initialData);
179
180
  const isInitializingRef = useRef(false);
@@ -248,7 +249,7 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
248
249
  ? nextExplanation
249
250
  : '');
250
251
  }
251
- setAnswers(initialData?.answers || {});
252
+ setAnswers(normalizeWfsfAnswersMap(initialData?.answers || {}));
252
253
  setBlankStyle(initialData?.blankStyle);
253
254
  const nextMode = detectWfsfBankMode(initialData?.viewMode ?? viewModeProp, initialData?.wordBank);
254
255
  setBankMode(nextMode);
@@ -299,12 +300,31 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
299
300
  changed = true;
300
301
  } });
301
302
  expectedBlankKeys.forEach(k => { if (!(k in next)) {
302
- next[k] = '';
303
+ next[k] = [''];
303
304
  changed = true;
304
305
  } });
305
306
  return changed ? next : prev;
306
307
  });
307
308
  }, [expectedBlankKeys]);
309
+ const updateAnswerAlt = useCallback((key, index, value) => {
310
+ setAnswers((prev) => {
311
+ const current = [...(prev[key] || [''])];
312
+ current[index] = value;
313
+ return { ...prev, [key]: current };
314
+ });
315
+ }, []);
316
+ const addAnswerAlt = useCallback((key) => {
317
+ setAnswers((prev) => ({ ...prev, [key]: [...(prev[key] || ['']), ''] }));
318
+ }, []);
319
+ const removeAnswerAlt = useCallback((key, index) => {
320
+ setAnswers((prev) => {
321
+ const current = [...(prev[key] || [''])];
322
+ if (current.length <= 1)
323
+ return { ...prev, [key]: [''] };
324
+ current.splice(index, 1);
325
+ return { ...prev, [key]: current };
326
+ });
327
+ }, []);
308
328
  // Keep WITH_OPTIONS bank length aligned to blank keys
309
329
  useEffect(() => {
310
330
  if (bankMode !== 'WITH_OPTIONS')
@@ -413,7 +433,7 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
413
433
  if (expectedBlankKeys.length === 0)
414
434
  return false;
415
435
  for (const key of expectedBlankKeys) {
416
- if (!answers[key] || answers[key].trim() === '')
436
+ if (!hasWfsfBlankAnswer(answers[key]))
417
437
  return false;
418
438
  }
419
439
  if (bankMode === 'WITH_WORD_BANK') {
@@ -432,8 +452,10 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
432
452
  if (opts.length < 2)
433
453
  return false;
434
454
  const key = expectedBlankKeys[i];
435
- const ans = (answers[key] || '').trim();
436
- if (ans && !opts.includes(ans))
455
+ const alts = expandWfsfCorrectAlts(answers[key]);
456
+ if (alts.length === 0)
457
+ return false;
458
+ if (alts.some((alt) => !opts.includes(alt)))
437
459
  return false;
438
460
  }
439
461
  }
@@ -453,10 +475,10 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
453
475
  ]);
454
476
  // ── Preview mode ───────────────────────────────────────────────────────────
455
477
  if (isPreviewMode) {
456
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-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-teal-500 to-cyan-600 shadow-sm", children: _jsx(Eye, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n t\u1EEB \u2013 Xem tr\u01B0\u1EDBc" }), _jsx("p", { className: "text-xs text-gray-500", children: "Ch\u1EBF \u0111\u1ED9 xem tr\u01B0\u1EDBc v\u1EDBi \u0111\u00E1p \u00E1n \u0111\u00FAng \u0111\u00E3 \u0111\u01B0\u1EE3c \u0111i\u1EC1n s\u1EB5n" })] })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsPreviewMode(false), className: "gap-2 border-teal-200 text-teal-700 hover:bg-teal-50", children: [_jsx(Pencil, { className: "h-4 w-4" }), "Quay l\u1EA1i Edit"] })] }) }) }), _jsx(WordFillStructuredFormClient, { questionData: buildFormData(), isReviewMode: true, userAnswer: answers, instruction: instruction })] }));
478
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-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-teal-500 to-cyan-600 shadow-sm", children: _jsx(Eye, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n t\u1EEB \u2013 Xem tr\u01B0\u1EDBc" }), _jsx("p", { className: "text-xs text-gray-500", children: "Ch\u1EBF \u0111\u1ED9 xem tr\u01B0\u1EDBc v\u1EDBi \u0111\u00E1p \u00E1n \u0111\u00FAng \u0111\u00E3 \u0111\u01B0\u1EE3c \u0111i\u1EC1n s\u1EB5n" })] })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsPreviewMode(false), className: "gap-2 border-teal-200 text-teal-700 hover:bg-teal-50", children: [_jsx(Pencil, { className: "h-4 w-4" }), "Quay l\u1EA1i Edit"] })] }) }) }), _jsx(WordFillStructuredFormClient, { questionData: buildFormData(), isReviewMode: true, userAnswer: primaryWfsfAnswers(answers), instruction: instruction })] }));
457
479
  }
458
480
  // ── Edit mode ──────────────────────────────────────────────────────────────
459
- return (_jsxs("div", { className: "space-y-4", children: [isGrouped && effectiveGroupType === 'BASIC' && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `word-fill-structured-${partId ?? 'group'}` })), isGrouped && effectiveGroupType === 'DOCUMENT' && (_jsx(DocumentGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `word-fill-structured-${partId ?? 'document'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-100/50", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-teal-500 via-cyan-500 to-emerald-500" }), _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-gray-100", children: _jsx(PenLine, { className: "h-4 w-4 text-teal-600" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n t\u1EEB v\u00E0o bi\u1EC3u m\u1EABu c\u00F3 c\u1EA5u tr\u00FAc" }), _jsxs("p", { className: "text-xs text-gray-500", children: ["Nh\u1EADp n\u1ED9i dung, \u0111\u1EB7t ch\u1ED7 tr\u1ED1ng b\u1EB1ng ", `{0}`, " ", `{1}`, "\u2026 r\u1ED3i \u0111i\u1EC1n \u0111\u00E1p \u00E1n b\u00EAn d\u01B0\u1EDBi"] })] })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsPreviewMode(true), disabled: expectedBlankKeys.length === 0 || expectedBlankKeys.some(k => !answers[k]?.trim()), className: "gap-2 border-teal-200 text-teal-600 hover:bg-teal-50 hover:text-teal-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: "space-y-2", children: [_jsxs(Label, { htmlFor: "wfsf-instruction", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Info, { className: "h-4 w-4 text-teal-500" }), "Instruction", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(h\u01B0\u1EDBng d\u1EABn b\u00E0i t\u1EADp)" })] }), _jsx(Textarea, { id: "wfsf-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: "VD: Complete the words in the sentences. Use the words in the box.", rows: 2, className: "min-h-[64px] resize-y border-gray-200 bg-white transition-all focus:border-teal-300 focus:ring-teal-100" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(PointsInput, { value: points, onChange: (e) => setPoints(Number(e.target.value) || 1) }), _jsxs("div", { role: "button", tabIndex: 0, onClick: () => setCaseSensitive(!caseSensitive), onKeyDown: (e) => { if (e.key === ' ' || e.key === 'Enter')
481
+ return (_jsxs("div", { className: "space-y-4", children: [isGrouped && effectiveGroupType === 'BASIC' && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `word-fill-structured-${partId ?? 'group'}` })), isGrouped && effectiveGroupType === 'DOCUMENT' && (_jsx(DocumentGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `word-fill-structured-${partId ?? 'document'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-100/50", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-teal-500 via-cyan-500 to-emerald-500" }), _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-gray-100", children: _jsx(PenLine, { className: "h-4 w-4 text-teal-600" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n t\u1EEB v\u00E0o bi\u1EC3u m\u1EABu c\u00F3 c\u1EA5u tr\u00FAc" }), _jsxs("p", { className: "text-xs text-gray-500", children: ["Nh\u1EADp n\u1ED9i dung, \u0111\u1EB7t ch\u1ED7 tr\u1ED1ng b\u1EB1ng ", `{0}`, " ", `{1}`, "\u2026 r\u1ED3i \u0111i\u1EC1n \u0111\u00E1p \u00E1n b\u00EAn d\u01B0\u1EDBi"] })] })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsPreviewMode(true), disabled: expectedBlankKeys.length === 0 || expectedBlankKeys.some(k => !hasWfsfBlankAnswer(answers[k])), className: "gap-2 border-teal-200 text-teal-600 hover:bg-teal-50 hover:text-teal-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: "space-y-2", children: [_jsxs(Label, { htmlFor: "wfsf-instruction", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Info, { className: "h-4 w-4 text-teal-500" }), "Instruction", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(h\u01B0\u1EDBng d\u1EABn b\u00E0i t\u1EADp)" })] }), _jsx(Textarea, { id: "wfsf-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: "VD: Complete the words in the sentences. Use the words in the box.", rows: 2, className: "min-h-[64px] resize-y border-gray-200 bg-white transition-all focus:border-teal-300 focus:ring-teal-100" })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(PointsInput, { value: points, onChange: (e) => setPoints(Number(e.target.value) || 1) }), _jsxs("div", { role: "button", tabIndex: 0, onClick: () => setCaseSensitive(!caseSensitive), onKeyDown: (e) => { if (e.key === ' ' || e.key === 'Enter')
460
482
  setCaseSensitive(!caseSensitive); }, style: { paddingBlock: 'calc(var(--spacing) * 1)' }, className: `inline-flex cursor-pointer items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${caseSensitive
461
483
  ? 'border-teal-300 bg-teal-50 text-teal-700'
462
484
  : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Switch, { checked: caseSensitive, onCheckedChange: setCaseSensitive, className: caseSensitive ? 'data-[state=checked]:bg-teal-500 pointer-events-none' : 'pointer-events-none', tabIndex: -1 }), "Ph\u00E2n bi\u1EC7t Hoa / th\u01B0\u1EDDng"] })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "wfsf-title", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-teal-500" }), "Ti\u00EAu \u0111\u1EC1", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(Input, { id: "wfsf-title", value: effectiveTitle, onChange: (e) => setEffectiveTitle(e.target.value), placeholder: "Nh\u1EADp ti\u00EAu \u0111\u1EC1 c\u00E2u h\u1ECFi...", className: "border-gray-200 bg-white transition-all focus:border-teal-300 focus:ring-teal-100" })] }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", 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-teal-500" }), "File \u00E2m thanh (Audio)", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setAudioUrls((prev) => [...prev, '']), className: "gap-1.5 border-teal-200 text-teal-600 hover:bg-teal-50 hover:text-teal-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm audio"] })] }), audioUrls.length === 0 ? (_jsx("div", { className: "flex items-center justify-between rounded-lg border border-dashed border-gray-200 bg-gray-50/50 p-3 text-xs text-gray-500", children: _jsx("span", { children: "Ch\u01B0a c\u00F3 file audio. Nh\u1EA5n \"Th\u00EAm audio\" \u0111\u1EC3 upload file nghe cho c\u00E2u h\u1ECFi." }) })) : (_jsx("div", { className: "space-y-2.5", children: audioUrls.map((audioUrl, index) => (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50/30 p-2.5", children: [_jsx("span", { className: "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-teal-100 text-xs font-semibold text-teal-700", children: index + 1 }), _jsx("div", { className: "flex-1", children: _jsx(FileUpload, { id: `wfsf-audio-${index}`, label: "", accept: "audio/*", value: audioUrl, onChange: (url) => {
@@ -475,10 +497,11 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
475
497
  });
476
498
  }, onRemove: () => {
477
499
  setImageUrls((prev) => prev.filter((_, idx) => idx !== index));
478
- } }, index))) }))] }), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, i) => _jsx("li", { children: error }, i)) })] })] }))] })] }), showContent && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-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-6 w-6 items-center justify-center rounded-md bg-teal-100", children: _jsx(ListOrdered, { className: "h-3.5 w-3.5 text-teal-600" }) }), _jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "N\u1ED9i dung c\u1EA5u tr\u00FAc" })] }), expectedBlankKeys.length > 0 && (_jsxs("span", { className: "inline-flex items-center rounded-full border border-teal-200 bg-teal-50 px-2.5 py-0.5 text-xs font-semibold text-teal-700", children: [expectedBlankKeys.length, " ch\u1ED7 tr\u1ED1ng"] }))] }) }), _jsxs(CardContent, { className: "space-y-3 px-4 pb-4", children: [_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 transition-all focus-within:border-teal-300 focus-within:ring-2 focus-within:ring-teal-100", children: [_jsx(EditorToolbar, { editor: editor }), _jsx(EditorContent, { editor: editor })] }), _jsxs("div", { className: "flex items-start gap-2 rounded-lg border border-teal-100 bg-teal-50/60 px-3 py-2.5", children: [_jsx(Info, { className: "mt-0.5 h-4 w-4 shrink-0 text-teal-500" }), _jsxs("p", { className: "text-xs text-teal-700", children: ["Nh\u1EADp ", _jsx("strong", { children: `{0}` }), ", ", _jsx("strong", { children: `{1}` }), ", ", _jsx("strong", { children: `{2}` }), "\u2026 v\u00E0o v\u0103n b\u1EA3n \u0111\u1EC3 t\u1EA1o \u00F4 tr\u1ED1ng. \u0110\u00E1p \u00E1n s\u1EBD t\u1EF1 \u0111\u1ED9ng xu\u1EA5t hi\u1EC7n b\u00EAn d\u01B0\u1EDBi \u0111\u1EC3 b\u1EA1n nh\u1EADp."] })] })] })] })), _jsx(WfsfWordBankEditor, { mode: bankMode, onModeChange: handleBankModeChange, sharedBank: sharedBank, onSharedBankChange: setSharedBank, optionsBank: optionsBank, onOptionsBankChange: setOptionsBank, blankKeys: expectedBlankKeys, answers: answers, onAnswerChange: (key, value) => setAnswers((prev) => ({ ...prev, [key]: value })) }), expectedBlankKeys.length > 0 && bankMode !== 'WITH_OPTIONS' && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-teal-100", children: _jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-teal-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["\u0110\u00E1p \u00E1n ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", expectedBlankKeys.length, " ch\u1ED7 tr\u1ED1ng)"] })] })] }) }), _jsxs(CardContent, { className: "px-4 pb-4", children: [_jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4", children: expectedBlankKeys.map((key) => {
479
- const isEmpty = !answers[key] || answers[key].trim() === '';
500
+ } }, index))) }))] }), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, i) => _jsx("li", { children: error }, i)) })] })] }))] })] }), showContent && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-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-6 w-6 items-center justify-center rounded-md bg-teal-100", children: _jsx(ListOrdered, { className: "h-3.5 w-3.5 text-teal-600" }) }), _jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "N\u1ED9i dung c\u1EA5u tr\u00FAc" })] }), expectedBlankKeys.length > 0 && (_jsxs("span", { className: "inline-flex items-center rounded-full border border-teal-200 bg-teal-50 px-2.5 py-0.5 text-xs font-semibold text-teal-700", children: [expectedBlankKeys.length, " ch\u1ED7 tr\u1ED1ng"] }))] }) }), _jsxs(CardContent, { className: "space-y-3 px-4 pb-4", children: [_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 transition-all focus-within:border-teal-300 focus-within:ring-2 focus-within:ring-teal-100", children: [_jsx(EditorToolbar, { editor: editor }), _jsx(EditorContent, { editor: editor })] }), _jsxs("div", { className: "flex items-start gap-2 rounded-lg border border-teal-100 bg-teal-50/60 px-3 py-2.5", children: [_jsx(Info, { className: "mt-0.5 h-4 w-4 shrink-0 text-teal-500" }), _jsxs("p", { className: "text-xs text-teal-700", children: ["Nh\u1EADp ", _jsx("strong", { children: `{0}` }), ", ", _jsx("strong", { children: `{1}` }), ", ", _jsx("strong", { children: `{2}` }), "\u2026 v\u00E0o v\u0103n b\u1EA3n \u0111\u1EC3 t\u1EA1o \u00F4 tr\u1ED1ng. \u0110\u00E1p \u00E1n s\u1EBD t\u1EF1 \u0111\u1ED9ng xu\u1EA5t hi\u1EC7n b\u00EAn d\u01B0\u1EDBi \u0111\u1EC3 b\u1EA1n nh\u1EADp."] })] })] })] })), _jsx(WfsfWordBankEditor, { mode: bankMode, onModeChange: handleBankModeChange, sharedBank: sharedBank, onSharedBankChange: setSharedBank, optionsBank: optionsBank, onOptionsBankChange: setOptionsBank, blankKeys: expectedBlankKeys, answers: answers, onAnswerChange: (key, value) => setAnswers((prev) => ({ ...prev, [key]: value })) }), expectedBlankKeys.length > 0 && bankMode !== 'WITH_OPTIONS' && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-teal-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-teal-100", children: _jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-teal-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["\u0110\u00E1p \u00E1n ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", expectedBlankKeys.length, " ch\u1ED7 tr\u1ED1ng)"] })] })] }) }), _jsxs(CardContent, { className: "px-4 pb-4", children: [_jsx("p", { className: "mb-3 text-xs text-gray-500", children: "M\u1ED7i ch\u1ED7 tr\u1ED1ng c\u00F3 th\u1EC3 c\u00F3 nhi\u1EC1u \u0111\u00E1p \u00E1n \u0111\u00FAng. H\u1ECDc vi\u00EAn \u0111\u00FAng n\u1EBFu kh\u1EDBp m\u1ED9t trong c\u00E1c \u0111\u00E1p \u00E1n." }), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4", children: expectedBlankKeys.map((key) => {
501
+ const alts = answers[key]?.length ? answers[key] : [''];
502
+ const isEmpty = !hasWfsfBlankAnswer(alts);
480
503
  return (_jsxs("div", { className: `rounded-xl border-2 p-3 transition-all duration-200 ${isEmpty
481
504
  ? 'border-gray-200 bg-white'
482
- : 'border-teal-200 bg-gradient-to-br from-teal-50 to-cyan-50 shadow-sm shadow-teal-100'}`, children: [_jsxs(Label, { className: "mb-1.5 flex items-center justify-between text-sm font-semibold text-gray-700", children: [_jsxs("span", { children: ["Ch\u1ED7 tr\u1ED1ng ", _jsx("code", { className: "rounded bg-gray-100 px-1 text-teal-700", children: `{${key}}` })] }), isEmpty && (_jsx("span", { className: "text-[10px] font-normal italic text-red-500", children: "*B\u1EAFt bu\u1ED9c" }))] }), _jsx(Input, { value: answers[key] || '', onChange: e => setAnswers(prev => ({ ...prev, [key]: e.target.value })), placeholder: "\u0110\u00E1p \u00E1n \u0111\u00FAng...", className: `border-gray-200 bg-white transition-all focus:border-teal-300 focus:ring-teal-100 ${!isEmpty ? 'border-teal-200' : ''}` }), !isEmpty && (_jsxs("span", { className: "mt-1.5 inline-flex items-center gap-1 rounded-full bg-teal-100 px-2 py-0.5 text-[10px] font-semibold text-teal-700", children: [_jsx(CheckCircle2, { className: "h-3 w-3" }), "\u0110\u00E3 nh\u1EADp"] }))] }, key));
483
- }) }), expectedBlankKeys.some(k => !answers[k]?.trim()) && (_jsxs("div", { className: "mt-4 flex items-start gap-3 rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-amber-100", children: _jsx("span", { className: "text-lg", children: "\u26A0\uFE0F" }) }), _jsxs("div", { className: "text-sm text-amber-800", children: [_jsx("p", { className: "font-semibold", children: "C\u00F2n ch\u1ED7 tr\u1ED1ng ch\u01B0a c\u00F3 \u0111\u00E1p \u00E1n" }), _jsxs("p", { className: "mt-0.5 text-amber-700", children: ["Vui l\u00F2ng \u0111i\u1EC1n \u0111\u00E1p \u00E1n \u0111\u00FAng cho t\u1EA5t c\u1EA3 c\u00E1c \u00F4 \u0111\u01B0\u1EE3c \u0111\u00E1nh d\u1EA5u ", _jsx("span", { className: "text-red-500 font-medium", children: "*B\u1EAFt bu\u1ED9c" }), "."] })] })] }))] })] })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { value: explanation, onChange: (e) => setExplanation(e.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] resize-none border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200" }) })] })] }));
505
+ : 'border-teal-200 bg-gradient-to-br from-teal-50 to-cyan-50 shadow-sm shadow-teal-100'}`, children: [_jsxs(Label, { className: "mb-1.5 flex items-center justify-between text-sm font-semibold text-gray-700", children: [_jsxs("span", { children: ["Ch\u1ED7 tr\u1ED1ng ", _jsx("code", { className: "rounded bg-gray-100 px-1 text-teal-700", children: `{${key}}` })] }), isEmpty && (_jsx("span", { className: "text-[10px] font-normal italic text-red-500", children: "*B\u1EAFt bu\u1ED9c" }))] }), _jsx("div", { className: "space-y-1.5", children: alts.map((alt, altIndex) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { value: alt, onChange: (e) => updateAnswerAlt(key, altIndex, e.target.value), placeholder: altIndex === 0 ? 'Đáp án đúng...' : `Đáp án thay thế ${altIndex + 1}`, className: `border-gray-200 bg-white transition-all focus:border-teal-300 focus:ring-teal-100 ${!isEmpty ? 'border-teal-200' : ''}` }), alts.length > 1 && (_jsx("button", { type: "button", title: "X\u00F3a \u0111\u00E1p \u00E1n n\u00E0y", onClick: () => removeAnswerAlt(key, altIndex), className: "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-400 hover:bg-rose-50 hover:text-rose-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] }, `${key}-${altIndex}`))) }), _jsxs("button", { type: "button", onClick: () => addAnswerAlt(key), className: "mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-teal-700 hover:text-teal-800", children: [_jsx(Plus, { className: "h-3 w-3" }), "Th\u00EAm \u0111\u00E1p \u00E1n \u0111\u00FAng"] })] }, key));
506
+ }) }), expectedBlankKeys.some(k => !hasWfsfBlankAnswer(answers[k])) && (_jsxs("div", { className: "mt-4 flex items-start gap-3 rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-amber-100", children: _jsx("span", { className: "text-lg", children: "\u26A0\uFE0F" }) }), _jsxs("div", { className: "text-sm text-amber-800", children: [_jsx("p", { className: "font-semibold", children: "C\u00F2n ch\u1ED7 tr\u1ED1ng ch\u01B0a c\u00F3 \u0111\u00E1p \u00E1n" }), _jsxs("p", { className: "mt-0.5 text-amber-700", children: ["Vui l\u00F2ng \u0111i\u1EC1n \u0111\u00E1p \u00E1n \u0111\u00FAng cho t\u1EA5t c\u1EA3 c\u00E1c \u00F4 \u0111\u01B0\u1EE3c \u0111\u00E1nh d\u1EA5u ", _jsx("span", { className: "text-red-500 font-medium", children: "*B\u1EAFt bu\u1ED9c" }), "."] })] })] }))] })] })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { value: explanation, onChange: (e) => setExplanation(e.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] resize-none border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200" }) })] })] }));
484
507
  }
@@ -1,18 +1,10 @@
1
+ import { normalizeWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
1
2
  import { detectWfsfBankMode, normalizeOptionsWordBank, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
2
3
  function asRecord(value) {
3
4
  return value && typeof value === 'object' && !Array.isArray(value)
4
5
  ? value
5
6
  : {};
6
7
  }
7
- function asStringAnswers(value) {
8
- const record = asRecord(value);
9
- const answers = {};
10
- for (const [key, entry] of Object.entries(record)) {
11
- if (typeof entry === 'string')
12
- answers[key] = entry;
13
- }
14
- return answers;
15
- }
16
8
  function asBlankStyle(value) {
17
9
  if (value === 'letter' ||
18
10
  value === 'word' ||
@@ -104,7 +96,7 @@ export function mapQuestionToWfsfData(question) {
104
96
  audioUrl: audioUrls[0] || String(apiContent.audioUrl || answer.audioUrl || source.audio_url || ''),
105
97
  audioUrls,
106
98
  content,
107
- answers: asStringAnswers(correctAnswer.answers || answer.answers),
99
+ answers: normalizeWfsfAnswersMap(correctAnswer.answers || answer.answers),
108
100
  caseSensitive: Boolean(correctAnswer.caseSensitive ?? answer.caseSensitive ?? false),
109
101
  blankStyle: asBlankStyle(apiContent.blankStyle ?? answer.blankStyle),
110
102
  viewMode,
@@ -1,3 +1,4 @@
1
+ import { compactWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
1
2
  /**
2
3
  * Transform WORD_FILL_STRUCTURED_FORM creator data → API format
3
4
  *
@@ -61,7 +62,7 @@ export const transformWordFillStructuredForm = (question) => {
61
62
  apiContent.wordBank = answerData.wordBank;
62
63
  }
63
64
  const apiCorrectAnswer = {
64
- answers: answerData.answers,
65
+ answers: compactWfsfAnswersMap((answerData.answers || {})),
65
66
  caseSensitive: answerData.caseSensitive ?? false,
66
67
  };
67
68
  const basicGroupData = hasGroupData
@@ -18,7 +18,6 @@ import { WriteAShortLetterClient } from '../types/write-a-short-letter/WriteASho
18
18
  import { LookPictureChooseCorrectAnswerClient } from '../types/look-picture-choose-correct-answer/LookPictureChooseCorrectAnswerClient';
19
19
  import { LookPictureFillBlankChooseAnswerClient } from '../types/look-picture-fill-blank-choose-answer/LookPictureFillBlankChooseAnswerClient';
20
20
  import { FillMissingWordsInGridClient } from '../types/fill-missing-words-in-grid/FillMissingWordsInGridClient';
21
- import { WordFillStructuredFormClient } from '../types/word-fill-structured-form/WordFillStructuredFormClient';
22
21
  import { CrosswordPuzzleClient } from '../types/crossword-puzzle/CrosswordPuzzleClient';
23
22
  import { FindWordsInMatrixClient } from '../types/find-words-in-matrix/FindWordsInMatrixClient';
24
23
  import { SortWordsIntoCategoriesViewer } from './SortWordsIntoCategoriesViewer';
@@ -517,72 +516,6 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
517
516
  onSubmitAnswer?.(answers);
518
517
  } }));
519
518
  };
520
- const renderWordFillStructuredForm = () => {
521
- const content = (question.content && typeof question.content === 'object'
522
- ? question.content
523
- : {});
524
- // Pearson preview maps correctAnswer → answer; bank may use either shape.
525
- const answer = (question.answer ??
526
- question.correctAnswer ??
527
- {});
528
- const answers = answer?.answers && typeof answer.answers === 'object' && !Array.isArray(answer.answers)
529
- ? answer.answers
530
- : {};
531
- const html = String(content.content || '');
532
- const blankRaw = String(content.blankStyle || '').toLowerCase();
533
- const blankStyle = blankRaw === 'letter' || blankRaw === 'box'
534
- ? 'letter'
535
- : blankRaw === 'phrase' || blankRaw === 'sentence'
536
- ? 'phrase'
537
- : 'word';
538
- const userMap = userAnswer && typeof userAnswer === 'object' && !Array.isArray(userAnswer)
539
- ? userAnswer
540
- : selectedAnswer && typeof selectedAnswer === 'object' && !Array.isArray(selectedAnswer)
541
- ? selectedAnswer
542
- : isReviewMode
543
- ? answers
544
- : {};
545
- const qNum = typeof question.questionNumber === 'number' && question.questionNumber > 0
546
- ? question.questionNumber
547
- : undefined;
548
- const rawWordBank = Array.isArray(content.wordBank) ? content.wordBank : undefined;
549
- const wordBank = rawWordBank?.map((entry) => {
550
- if (entry == null || typeof entry !== 'object')
551
- return null;
552
- const rec = entry;
553
- if (Array.isArray(rec.options)) {
554
- const options = rec.options.map((o) => String(o ?? '').trim()).filter(Boolean);
555
- return options.length >= 2 ? { options } : null;
556
- }
557
- const word = String(rec.word ?? '').trim();
558
- if (word)
559
- return rec.isExample ? { word, isExample: true } : { word };
560
- return null;
561
- });
562
- const hasWordOptions = Boolean(wordBank?.some((e) => e != null &&
563
- 'options' in e &&
564
- Array.isArray(e.options) &&
565
- (e.options?.length ?? 0) >= 2));
566
- const hasSharedWords = Boolean(wordBank?.some((e) => e != null && 'word' in e && e.word));
567
- const viewMode = String(content.viewMode || '').trim() ||
568
- (hasWordOptions ? 'WITH_OPTIONS' : hasSharedWords ? 'WITH_WORD_BANK' : undefined);
569
- return (_jsx(WordFillStructuredFormClient, { questionData: {
570
- title: String(content.title || ''),
571
- imageUrl: content.imageUrl ? String(content.imageUrl) : undefined,
572
- content: html,
573
- blankStyle,
574
- answers,
575
- caseSensitive: Boolean(answer?.caseSensitive),
576
- explanation: question.explanation,
577
- ...(viewMode ? { viewMode } : {}),
578
- ...((hasWordOptions || hasSharedWords) && wordBank ? { wordBank } : {}),
579
- }, userAnswer: userMap, isReviewMode: isReviewMode, autoFillCorrectAnswers: isReviewMode && Object.keys(userMap).length === 0, explanation: question.explanation, questionNumber: qNum, onAnswerChange: (key, value) => {
580
- const prev = selectedAnswer && typeof selectedAnswer === 'object' && !Array.isArray(selectedAnswer)
581
- ? selectedAnswer
582
- : {};
583
- setSelectedAnswer({ ...prev, [key]: value });
584
- } }));
585
- };
586
519
  const renderWordFillParagraph = () => {
587
520
  const content = (question.content && typeof question.content === 'object'
588
521
  ? question.content
@@ -1,5 +1,5 @@
1
1
  import type { FillBlankQuestion } from '../../../components/exams/take/types';
2
- import { type ImageLabelData } from '../../questions/_shared/types/question-group.type';
2
+ import type { ImageLabelData } from '../../questions/_shared/types/question-group.type';
3
3
  interface ReviewListenAndWriteRendererProps {
4
4
  partNumber: number;
5
5
  questionCount: number;
@@ -7,7 +7,7 @@ import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-
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
9
  import { ImageLabelWordBank } from '../../questions/groups/ImageLabelWordBank';
10
- import { hasImageLabels } from '../../questions/_shared/types/question-group.type';
10
+ import { hasImageLabels } from '../../questions/_shared/types/question-group.utils';
11
11
  export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, imageLabels, }) {
12
12
  /**
13
13
  * Get answer status from API's isCorrect field
@@ -1,6 +1,15 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Info, Megaphone } from 'lucide-react';
4
+ /** CMS instruction may be plain text or HTML (e.g. `<p>…</p>`). */
5
+ function isHtmlContent(value) {
6
+ return /<[a-z][\s\S]*>/i.test(value);
7
+ }
8
+ function InstructionHtml({ html, className }) {
9
+ return (_jsx("div", { className: className,
10
+ // Exam CMS content — not end-user input.
11
+ dangerouslySetInnerHTML: { __html: html } }));
12
+ }
4
13
  export default function CambridgeYleInstructionBanner({ instruction, instructions, hasExample = false, children, isReviewMode = false, compact = false, }) {
5
14
  if (isReviewMode)
6
15
  return null;
@@ -11,9 +20,9 @@ export default function CambridgeYleInstructionBanner({ instruction, instruction
11
20
  ? 'flex w-full items-center gap-2 rounded-[14px] border-2 border-dashed border-blue-300 bg-[#F8FBFF] px-3 py-2 transition-all hover:bg-blue-50/50 lg:gap-3 lg:px-4 lg:py-2.5 xl:gap-5 xl:rounded-[24px] xl:px-5 xl:py-4'
12
21
  : 'flex w-full items-center gap-4 rounded-[24px] border-2 border-dashed border-blue-300 bg-[#F8FBFF] px-5 py-4 transition-all hover:bg-blue-50/50 sm:gap-5', children: [_jsx("div", { className: compact
13
22
  ? 'flex h-8 w-8 shrink-0 -rotate-6 items-center justify-center rounded-[10px] bg-[#0070FF] shadow-[3px_3px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 lg:h-9 lg:w-9 xl:h-14 xl:w-14 xl:rounded-[16px] xl:shadow-[4px_4px_0px_rgba(191,219,254,1)]'
14
- : 'flex h-12 w-12 shrink-0 -rotate-6 items-center justify-center rounded-[16px] bg-[#0070FF] shadow-[4px_4px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 sm:h-14 sm:w-14', children: _jsx(Megaphone, { className: compact ? 'h-4 w-4 text-white lg:h-5 lg:w-5 xl:h-7 xl:w-7' : 'h-6 w-6 text-white sm:h-7 sm:w-7', strokeWidth: 2.5 }) }), _jsx("div", { className: "flex flex-1 flex-col justify-center", children: _jsxs("p", { className: compact
23
+ : 'flex h-12 w-12 shrink-0 -rotate-6 items-center justify-center rounded-[16px] bg-[#0070FF] shadow-[4px_4px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 sm:h-14 sm:w-14', children: _jsx(Megaphone, { className: compact ? 'h-4 w-4 text-white lg:h-5 lg:w-5 xl:h-7 xl:w-7' : 'h-6 w-6 text-white sm:h-7 sm:w-7', strokeWidth: 2.5 }) }), _jsx("div", { className: "flex flex-1 flex-col justify-center", children: _jsxs("div", { className: compact
15
24
  ? 'text-[12px] font-black uppercase leading-tight tracking-wide text-[#002868] lg:text-[13px] xl:text-[18px] xl:leading-snug'
16
- : 'text-[15px] font-black uppercase leading-snug tracking-wide text-[#002868] sm:text-[18px]', style: { fontFamily: '"SVN-Volte Rounded", "Quicksand", sans-serif' }, children: [instruction, hasExample && (_jsx("span", { className: compact
25
+ : 'text-[15px] font-black uppercase leading-snug tracking-wide text-[#002868] sm:text-[18px]', style: { fontFamily: '"SVN-Volte Rounded", "Quicksand", sans-serif' }, children: [isHtmlContent(instruction) ? (_jsx(InstructionHtml, { html: instruction, className: "contents [&_p]:m-0 [&_p]:inline" })) : (instruction), hasExample && (_jsx("span", { className: compact
17
26
  ? 'ml-1 inline-block rounded-full bg-blue-100 px-1.5 py-0.5 align-middle text-[10px] font-bold tracking-normal text-[#0070FF] xl:-mt-1 xl:ml-2 xl:px-3 xl:text-[16px]'
18
- : 'ml-2 -mt-1 inline-block rounded-full bg-blue-100 px-3 py-0.5 align-middle text-[14px] font-bold tracking-normal text-[#0070FF] sm:text-[16px]', children: "THERE IS ONE EXAMPLE" }))] }) })] })), children, hasInstructionsList && (_jsx("div", { className: "rounded-lg border border-blue-200 bg-white p-4 shadow-sm", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("h4", { className: "flex items-center gap-2 font-semibold text-blue-900", children: [_jsx(Info, { className: "h-4 w-4" }), "Instructions"] }), _jsx("ol", { className: "list-inside list-decimal space-y-1.5 text-sm text-gray-700", children: instructions.map((item, index) => (_jsx("li", { className: "leading-relaxed", children: item }, index))) })] }) }))] }));
27
+ : 'ml-2 -mt-1 inline-block rounded-full bg-blue-100 px-3 py-0.5 align-middle text-[14px] font-bold tracking-normal text-[#0070FF] sm:text-[16px]', children: "THERE IS ONE EXAMPLE" }))] }) })] })), children, hasInstructionsList && (_jsx("div", { className: "rounded-lg border border-blue-200 bg-white p-4 shadow-sm", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("h4", { className: "flex items-center gap-2 font-semibold text-blue-900", children: [_jsx(Info, { className: "h-4 w-4" }), "Instructions"] }), _jsx("ol", { className: "list-inside list-decimal space-y-1.5 text-sm text-gray-700", children: instructions.map((item, index) => (_jsx("li", { className: "leading-relaxed", children: isHtmlContent(item) ? (_jsx(InstructionHtml, { html: item, className: "[&_p]:m-0" })) : (item) }, index))) })] }) }))] }));
19
28
  }
@@ -5,6 +5,7 @@ import { ResolvedImage } from '../../../components/common/ResolvedImage';
5
5
  import { cn } from '../../../shared/lib/utils';
6
6
  import { transformWordFillStructuredForm } from '../../../components/exams/take/exam-taking.utils';
7
7
  import { englishCertQuestionDomId } from './english-cert-scroll';
8
+ import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, } from '../../../shared/lib/utils/wfsf-blank-match';
8
9
  /**
9
10
  * Replace `{key}` placeholders in raw HTML with inline `<input>` elements so the
10
11
  * full HTML structure (tables, forms, …) is preserved and inputs render inside
@@ -25,14 +26,6 @@ function buildHtmlWithInputPlaceholders(html) {
25
26
  `;
26
27
  });
27
28
  }
28
- function normalizeStructuredAnswer(value, caseSensitive) {
29
- const normalized = value
30
- .trim()
31
- .replace(/[\p{P}]/gu, '')
32
- .replace(/\s+/g, ' ')
33
- .trim();
34
- return caseSensitive ? normalized : normalized.toLowerCase();
35
- }
36
29
  /**
37
30
  * Build the card label from the blank keys (which are the IELTS question numbers).
38
31
  * Returns "Questions x–y" for a numeric range, a single "Question n", or falls
@@ -82,28 +75,28 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
82
75
  const key = input.getAttribute('data-blank-key') || '';
83
76
  const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
84
77
  const userAnswer = initialAnswersRef.current[key] || '';
85
- const correctAnswer = question.answers[key] || '';
78
+ const correctAnswer = question.answers[key];
79
+ const correctLabel = formatWfsfCorrectLabel(correctAnswer);
86
80
  const hasUserAnswer = userAnswer.trim().length > 0;
87
81
  const isCorrect = hasUserAnswer &&
88
- normalizeStructuredAnswer(userAnswer, question.caseSensitive) ===
89
- normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
90
- const shouldShowReviewText = isReviewMode && !isCorrect && correctAnswer.trim().length > 0;
82
+ isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
83
+ const shouldShowReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
91
84
  input.value = shouldShowReviewText
92
- ? `${hasUserAnswer ? userAnswer : '(empty)'} → ${correctAnswer}`
85
+ ? `${hasUserAnswer ? userAnswer : '(empty)'} → ${correctLabel}`
93
86
  : userAnswer;
94
87
  input.disabled = isReviewMode;
95
88
  const blankStyle = question.blankStyle || 'word';
96
89
  const isLetter = blankStyle === 'letter';
97
- const isPhrase = blankStyle === 'phrase';
90
+ const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
98
91
  if (isLetter) {
99
- input.maxLength = Math.max(1, (correctAnswer || ' ').trim().length || 1);
92
+ const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
93
+ input.maxLength = Math.max(1, maxAltLen || 1);
100
94
  }
101
95
  input.className = cn('ec-blank-input inline-block bg-blue-50/30 text-base font-semibold leading-tight text-[#0f1730] align-baseline outline-none transition-all hover:bg-blue-50/60 focus:bg-blue-50/80', isLetter
102
96
  ? 'h-8 w-8 rounded border-2 border-[#1167e8] px-0 text-center uppercase'
103
97
  : isPhrase
104
- ? 'min-w-[9rem] max-w-[18rem] w-[14ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-2 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]'
105
- : // blankStyle=word compact for one token
106
- 'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-1 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]', isReviewMode
98
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-2 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]'
99
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-1 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]', isReviewMode
107
100
  ? isCorrect
108
101
  ? 'border-solid border-[#22c55e] bg-emerald-50 text-[#15803d]'
109
102
  : hasUserAnswer
@@ -111,7 +104,7 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
111
104
  : 'border-solid border-[#f97316] bg-amber-50 text-[#c2410c]'
112
105
  : '');
113
106
  if (!isLetter) {
114
- input.size = isPhrase ? 18 : 10;
107
+ input.size = isPhrase ? 28 : 10;
115
108
  }
116
109
  // letter inline box — host FE may not scan Tailwind
117
110
  if (isLetter) {
@@ -160,7 +153,7 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
160
153
  };
161
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
162
155
  }, [processedHtml, isReviewMode]);
163
- return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-[#0f1730]", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-[#d9e4f2]", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-56 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: "ec-structured-content w-full overflow-x-auto rounded-xl border border-[#d9e4f2] bg-[#f4f9ff] p-5 text-base leading-relaxed text-[#0f1730] [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#d9e4f2] [&_td]:px-4 [&_td]:py-3 [&_td]:align-top [&_th]:border [&_th]:border-[#d9e4f2] [&_th]:bg-[#e9f3ff] [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold" }), isReviewMode && Object.keys(question.answers).length > 0 && (_jsxs("div", { className: "rounded-xl border border-[#d9e4f2] bg-white/70 p-3", children: [_jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wider text-[#64748b]", children: "Correct answers" }), _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-[#b9edc8] bg-[#ddf8e7] px-2 py-0.5 text-xs font-semibold text-[#15803d]", children: [key, ": ", val] }, key))) })] }))] }));
156
+ return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-[#0f1730]", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-[#d9e4f2]", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-56 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: "ec-structured-content w-full overflow-x-auto rounded-xl border border-[#d9e4f2] bg-[#f4f9ff] p-5 text-base leading-relaxed text-[#0f1730] [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#d9e4f2] [&_td]:px-4 [&_td]:py-3 [&_td]:align-top [&_th]:border [&_th]:border-[#d9e4f2] [&_th]:bg-[#e9f3ff] [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold" }), isReviewMode && Object.keys(question.answers).length > 0 && (_jsxs("div", { className: "rounded-xl border border-[#d9e4f2] bg-white/70 p-3", children: [_jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wider text-[#64748b]", children: "Correct answers" }), _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-[#b9edc8] bg-[#ddf8e7] px-2 py-0.5 text-xs font-semibold text-[#15803d]", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] }))] }));
164
157
  });
165
158
  /**
166
159
  * Branded English Certification renderer for `WORD_FILL_STRUCTURED_FORM`.
@@ -21,8 +21,8 @@ export declare const ROUTES: {
21
21
  readonly USERS: "/manage/users";
22
22
  readonly USERS_CREATE: "/manage/users/create";
23
23
  readonly USERS_EDIT: (id: string) => string;
24
- readonly QUESTION_BANK: "/manage/question-bank";
25
- readonly QUESTION_CREATE: "/manage/question-bank/create";
24
+ readonly QUESTION_BANK: "/manage/question-banks";
25
+ readonly QUESTION_CREATE: "/manage/question-banks/create";
26
26
  readonly QUESTION_EDIT: (id: string) => string;
27
27
  readonly EXAMS: "/manage/exams";
28
28
  readonly EXAMS_CREATE: "/manage/exams/create";
@@ -26,9 +26,9 @@ export const ROUTES = {
26
26
  USERS: '/manage/users',
27
27
  USERS_CREATE: '/manage/users/create',
28
28
  USERS_EDIT: (id) => `/manage/users/${id}/edit`,
29
- QUESTION_BANK: '/manage/question-bank',
30
- QUESTION_CREATE: '/manage/question-bank/create',
31
- QUESTION_EDIT: (id) => `/manage/question-bank/${id}/edit`,
29
+ QUESTION_BANK: '/manage/question-banks',
30
+ QUESTION_CREATE: '/manage/question-banks/create',
31
+ QUESTION_EDIT: (id) => `/manage/question-banks/${id}/edit`,
32
32
  EXAMS: '/manage/exams',
33
33
  EXAMS_CREATE: '/manage/exams/create',
34
34
  EXAMS_EDIT: (id) => `/manage/exams/${id}/edit`,
@@ -0,0 +1,9 @@
1
+ export type WfsfCorrectValue = string | string[];
2
+ export declare function cleanWfsfWord(str: unknown, caseSensitive?: boolean): string;
3
+ export declare function expandWfsfCorrectAlts(correctVal: unknown): string[];
4
+ export declare function formatWfsfCorrectLabel(correctVal: unknown): string;
5
+ export declare function isWfsfBlankCorrect(userWord: string, correctVal: unknown, caseSensitive?: boolean): boolean;
6
+ export declare function normalizeWfsfAnswersMap(raw: unknown): Record<string, string[]>;
7
+ export declare function compactWfsfAnswersMap(answers: Record<string, string | string[]>): Record<string, string[]>;
8
+ export declare function primaryWfsfAnswers(answers: Record<string, string | string[]>): Record<string, string>;
9
+ export declare function hasWfsfBlankAnswer(value: unknown): boolean;
@@ -0,0 +1,60 @@
1
+ export function cleanWfsfWord(str, caseSensitive = false) {
2
+ const text = str == null ? '' : String(str);
3
+ const cleaned = text
4
+ .trim()
5
+ .replace(/[\p{P}]/gu, '')
6
+ .replace(/\s+/g, ' ')
7
+ .trim();
8
+ return caseSensitive ? cleaned : cleaned.toLowerCase();
9
+ }
10
+ export function expandWfsfCorrectAlts(correctVal) {
11
+ if (Array.isArray(correctVal)) {
12
+ return correctVal.map((v) => String(v ?? '').trim()).filter(Boolean);
13
+ }
14
+ if (correctVal == null)
15
+ return [];
16
+ const valStr = String(correctVal);
17
+ if (valStr.includes('/')) {
18
+ return valStr.split('/').map((s) => s.trim()).filter(Boolean);
19
+ }
20
+ const trimmed = valStr.trim();
21
+ return trimmed ? [trimmed] : [];
22
+ }
23
+ export function formatWfsfCorrectLabel(correctVal) {
24
+ return expandWfsfCorrectAlts(correctVal).join(' / ');
25
+ }
26
+ export function isWfsfBlankCorrect(userWord, correctVal, caseSensitive = false) {
27
+ const userClean = cleanWfsfWord(userWord, caseSensitive);
28
+ if (!userClean)
29
+ return false;
30
+ return expandWfsfCorrectAlts(correctVal).some((alt) => cleanWfsfWord(alt, caseSensitive) === userClean);
31
+ }
32
+ export function normalizeWfsfAnswersMap(raw) {
33
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
34
+ return {};
35
+ const result = {};
36
+ for (const [key, value] of Object.entries(raw)) {
37
+ const alts = expandWfsfCorrectAlts(value);
38
+ result[key] = alts.length > 0 ? alts : [''];
39
+ }
40
+ return result;
41
+ }
42
+ export function compactWfsfAnswersMap(answers) {
43
+ const result = {};
44
+ for (const [key, value] of Object.entries(answers)) {
45
+ const alts = expandWfsfCorrectAlts(value);
46
+ if (alts.length > 0)
47
+ result[key] = alts;
48
+ }
49
+ return result;
50
+ }
51
+ export function primaryWfsfAnswers(answers) {
52
+ const result = {};
53
+ for (const [key, value] of Object.entries(answers)) {
54
+ result[key] = expandWfsfCorrectAlts(value)[0] || '';
55
+ }
56
+ return result;
57
+ }
58
+ export function hasWfsfBlankAnswer(value) {
59
+ return expandWfsfCorrectAlts(value).length > 0;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",