@tinyweb_dev/oe-exam-sdk 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/api/exam-questions.d.ts +1 -1
  2. package/dist/api/exam-questions.js +1 -39
  3. package/dist/api/exam-questions.types.d.ts +0 -28
  4. package/dist/components/exams/ExamCreator.d.ts +1 -7
  5. package/dist/components/exams/ExamCreator.js +21 -57
  6. package/dist/components/exams/ExamQuestionsPageContainer.d.ts +0 -5
  7. package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
  8. package/dist/components/exams/api.d.ts +1 -1
  9. package/dist/components/exams/index.d.ts +1 -1
  10. package/dist/components/exams/take/components/QuestionRenderer.d.ts +1 -6
  11. package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
  12. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +1 -7
  13. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +22 -38
  14. package/dist/components/exams/take/exam-taking.utils.d.ts +2 -2
  15. package/dist/components/exams/take/exam-taking.utils.js +2 -0
  16. package/dist/components/exams/take/index.d.ts +0 -6
  17. package/dist/components/exams/take/index.js +0 -5
  18. package/dist/components/exams/take/utils/question-transformers.d.ts +2 -2
  19. package/dist/components/exams/take/utils/question-transformers.js +4 -25
  20. package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
  21. package/dist/components/questions/question-bank/index.d.ts +0 -1
  22. package/dist/components/questions/question-bank/index.js +0 -1
  23. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
  24. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
  25. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
  26. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
  27. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +3 -18
  28. package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -5
  29. package/dist/components/questions/viewer/QuestionViewer.js +0 -67
  30. package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
  31. package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
  32. package/dist/index.d.ts +3 -4
  33. package/dist/index.js +1 -2
  34. package/dist/shared/constants/ApiConstant.d.ts +0 -1
  35. package/dist/shared/constants/ApiConstant.js +0 -1
  36. package/dist/shared/constants/routes.d.ts +2 -2
  37. package/dist/shared/constants/routes.js +3 -3
  38. package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
  39. package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
  40. package/package.json +1 -1
  41. package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +0 -45
  42. package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +0 -209
  43. package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +0 -9
  44. package/dist/components/questions/question-bank/QuestionBankPickerRow.js +0 -20
  45. package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +0 -55
  46. package/dist/components/questions/question-bank/question-bank-picker.utils.js +0 -408
  47. package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
  48. package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
  49. package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
  50. package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
@@ -6,4 +6,4 @@ export declare function createMockExamQuestionsApi(seed: {
6
6
  exam: ExamWithTemplate;
7
7
  questions?: Partial<ExamQuestion>[];
8
8
  }): ExamQuestionsSdkApi;
9
- export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './exam-questions.types';
9
+ export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './exam-questions.types';
@@ -41,40 +41,17 @@ export function createExamQuestionsApi(options = {}) {
41
41
  },
42
42
  saveExam: async () => undefined,
43
43
  updatePartAudio: (input) => updatePartAudio(request, input),
44
- searchQuestions: async ({ questionType, keyword, bookId, unitId, lessonId, lessonIds, page, size, }) => {
44
+ searchQuestions: async ({ questionType, keyword, page, size }) => {
45
45
  const query = new URLSearchParams();
46
46
  if (questionType)
47
47
  query.set('questionType', questionType);
48
48
  if (keyword)
49
49
  query.set('keyword', keyword);
50
- if (bookId)
51
- query.set('bookId', bookId);
52
- if (unitId)
53
- query.set('unitId', unitId);
54
- if (lessonId)
55
- query.set('lessonId', lessonId);
56
- appendLessonIds(query, lessonIds);
57
50
  query.set('page', String(page));
58
51
  query.set('size', String(size));
59
52
  const suffix = query.toString() ? `?${query.toString()}` : '';
60
53
  return request(`${API_ENDPOINTS.MANAGE.QUESTION_BANKS}${suffix}`);
61
54
  },
62
- getQuestionFilters: async (input = {}) => {
63
- const query = new URLSearchParams();
64
- if (input.bookId)
65
- query.set('bookId', input.bookId);
66
- if (input.unitId)
67
- query.set('unitId', input.unitId);
68
- if (input.lessonId)
69
- query.set('lessonId', input.lessonId);
70
- appendLessonIds(query, input.lessonIds);
71
- const suffix = query.toString() ? `?${query.toString()}` : '';
72
- const result = await request(`${API_ENDPOINTS.MANAGE.QUESTION_BANKS}/filters${suffix}`);
73
- if (result && typeof result === 'object' && 'data' in result && result.data) {
74
- return result.data;
75
- }
76
- return result;
77
- },
78
55
  };
79
56
  }
80
57
  export const createExamQuestionsFetchApi = createExamQuestionsApi;
@@ -116,15 +93,6 @@ export function createMockExamQuestionsApi(seed) {
116
93
  })),
117
94
  meta: { totalItems: questions.size },
118
95
  }),
119
- getQuestionFilters: async () => ({
120
- questionTypes: Array.from(new Set(Array.from(questions.values())
121
- .map((q) => q.type)
122
- .filter((t) => Boolean(t))
123
- .map(String))),
124
- books: [],
125
- units: [],
126
- lessons: [],
127
- }),
128
96
  };
129
97
  }
130
98
  function resolveTransformOptions(input) {
@@ -159,12 +127,6 @@ function createRequest({ baseUrl = API_BASE_URL ?? '', fetcher = fetch, headers,
159
127
  return response.json();
160
128
  };
161
129
  }
162
- function appendLessonIds(query, lessonIds) {
163
- const ids = (lessonIds ?? []).map((id) => id.trim()).filter(Boolean);
164
- if (ids.length === 0)
165
- return;
166
- query.set('lessonIds', ids.join(','));
167
- }
168
130
  function toRecord(value) {
169
131
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
170
132
  }
@@ -7,8 +7,6 @@ export interface ExamQuestionsSdkApi {
7
7
  saveExam?: (input: SaveExamInput) => Promise<void>;
8
8
  updatePartAudio?: (input: UpdatePartAudioInput) => Promise<void>;
9
9
  searchQuestions?: (input: SearchQuestionBankInput) => Promise<SearchQuestionBankResult>;
10
- /** Distinct question types + cascade book/unit/lesson names from meta. */
11
- getQuestionFilters?: (input?: QuestionBankFiltersInput) => Promise<QuestionBankFiltersResult>;
12
10
  }
13
11
  export interface ExamQuestionsApiResponse<T> {
14
12
  data: T;
@@ -71,34 +69,9 @@ export interface UpdatePartAudioInput {
71
69
  export interface SearchQuestionBankInput {
72
70
  questionType?: QuestionType;
73
71
  keyword?: string;
74
- /** meta.bookId */
75
- bookId?: string;
76
- /** meta.unitId */
77
- unitId?: string;
78
- /** meta.lessonId */
79
- lessonId?: string;
80
- /** Session scope: multiple meta.lessonId (IN). */
81
- lessonIds?: string[];
82
72
  page: number;
83
73
  size: number;
84
74
  }
85
- export interface QuestionBankFiltersInput {
86
- bookId?: string;
87
- unitId?: string;
88
- lessonId?: string;
89
- lessonIds?: string[];
90
- }
91
- /** Cascade option: stable meta id + display label. */
92
- export interface QuestionBankFilterOption {
93
- id: string;
94
- name: string;
95
- }
96
- export interface QuestionBankFiltersResult {
97
- questionTypes: string[];
98
- books: QuestionBankFilterOption[];
99
- units: QuestionBankFilterOption[];
100
- lessons: QuestionBankFilterOption[];
101
- }
102
75
  export interface QuestionSearchItem {
103
76
  id: string;
104
77
  questionType: QuestionType | string;
@@ -113,7 +86,6 @@ export interface QuestionSearchItem {
113
86
  code?: string;
114
87
  title?: string | null;
115
88
  usageCount?: number;
116
- meta?: Record<string, unknown> | null;
117
89
  }
118
90
  export interface SearchQuestionBankResult {
119
91
  data: QuestionSearchItem[];
@@ -1,7 +1,6 @@
1
1
  import { type ExistingQuestionBank } from '../questions/components/QuestionBankOption';
2
- import { type QuestionBankLessonSuggestion, type QuestionBankPickerDefaultFilters } from '../questions/question-bank/QuestionBankPickerDialog';
3
2
  import type { ExamData, ExamQuestion, ExamTemplate } from '../../shared/types/entities/exam.types';
4
- import type { QuestionBankFiltersInput, QuestionBankFiltersResult, SaveQuestionInput, SearchQuestionBankInput, SearchQuestionBankResult } from '../../api/exam-questions.types';
3
+ import type { SaveQuestionInput, SearchQuestionBankInput, SearchQuestionBankResult } from '../../api/exam-questions.types';
5
4
  export interface ExamCreatorProps {
6
5
  template: ExamTemplate;
7
6
  examId?: string;
@@ -18,11 +17,6 @@ export interface ExamCreatorProps {
18
17
  onPartAudioChange?: (partId: string, audioUrl: string) => void | Promise<void>;
19
18
  partAudioOverrides?: Record<string, string>;
20
19
  searchQuestions?: (input: SearchQuestionBankInput) => Promise<SearchQuestionBankResult>;
21
- getQuestionFilters?: (input?: QuestionBankFiltersInput) => Promise<QuestionBankFiltersResult>;
22
- /** Prefill book/unit/lesson filters in the bank picker. */
23
- questionBankDefaultFilters?: QuestionBankPickerDefaultFilters;
24
- /** Quick-pick chips for lesson provenance. */
25
- questionBankLessonSuggestions?: QuestionBankLessonSuggestion[];
26
20
  saveExamRef?: React.MutableRefObject<(() => void) | null>;
27
21
  }
28
22
  export declare function ExamCreator(props: ExamCreatorProps): import("react").JSX.Element;
@@ -1,11 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { CheckCircle, ChevronLeft, ChevronRight, Library, Loader2, Save } from 'lucide-react';
4
+ import { CheckCircle, ChevronLeft, ChevronRight, Loader2, Save } from 'lucide-react';
5
5
  import { Button } from '../../components/ui/button';
6
6
  import { QuestionBankOption } from '../questions/components/QuestionBankOption';
7
7
  import { QuestionSearchDropdown } from '../questions/components/QuestionSearchDropdown';
8
- import { QuestionBankPickerDialog, } from '../questions/question-bank/QuestionBankPickerDialog';
9
8
  import { QuestionCreator } from '../questions/creator/QuestionCreator';
10
9
  import { useToast } from '../../shared/lib/hooks/useToast';
11
10
  import { useT } from '../../shared/lib/i18n';
@@ -51,7 +50,6 @@ export function ExamCreator(props) {
51
50
  const [selectedQuestionInfo, setSelectedQuestionInfo] = useState({});
52
51
  const [questionBankCodeError, setQuestionBankCodeError] = useState();
53
52
  const [questionBankTitleError, setQuestionBankTitleError] = useState();
54
- const [pickerOpen, setPickerOpen] = useState(false);
55
53
  const currentPart = getPartByQuestionIndex(template, currentQuestionIndex);
56
54
  const currentPartId = currentPart?.id ?? '';
57
55
  const currentConfig = currentPart ? getEffectiveQuestionConfig(currentPart, currentQuestionIndex) : undefined;
@@ -200,64 +198,30 @@ export function ExamCreator(props) {
200
198
  setIsSavingQuestion(false);
201
199
  }
202
200
  };
203
- const applyBankItemToIndex = useCallback((item, targetIndex) => {
204
- const questionType = 'questionType' in item ? item.questionType : item.type;
205
- const correctAnswer = 'correctAnswer' in item ? item.correctAnswer : item.answer;
201
+ const handleExistingQuestionSelect = (question) => {
206
202
  const transformed = transformApiQuestionToFrontend({
207
- question_type: questionType,
208
- content: item.content,
209
- correct_answer: correctAnswer,
210
- points: item.points,
211
- difficult_level: item.difficulty,
212
- question_skill_type: item.skill,
213
- explanation: item.explanation,
203
+ question_type: question.type,
204
+ content: question.content,
205
+ correct_answer: question.answer,
206
+ points: question.points,
207
+ difficult_level: question.difficulty,
208
+ question_skill_type: question.skill,
209
+ explanation: question.explanation,
214
210
  });
215
- const part = getPartByQuestionIndex(template, targetIndex);
216
- const selectedQuestion = {
217
- ...transformed,
218
- id: undefined,
219
- partId: part?.id ?? currentPartId,
220
- questionNumber: targetIndex + 1,
221
- isCompleted: false,
222
- };
223
- upsertQuestionData(targetIndex, selectedQuestion);
224
- const bankId = item.id;
225
- const code = 'code' in item ? item.code : undefined;
226
- const title = 'title' in item ? (item.title ?? undefined) : undefined;
211
+ const selectedQuestion = { ...transformed, id: undefined, partId: currentPartId, questionNumber: currentQuestionIndex + 1, isCompleted: false };
212
+ upsertQuestionData(currentQuestionIndex, selectedQuestion);
213
+ const bankId = question.id;
214
+ const code = question.code;
215
+ const title = question.title;
227
216
  if (code || title)
228
- setSelectedQuestionInfo((prev) => ({ ...prev, [targetIndex]: { code, title: title ?? undefined } }));
229
- if (bankId && (code || title)) {
230
- setExistingQuestionBanks((prev) => ({
231
- ...prev,
232
- [targetIndex]: { id: bankId, code: code ?? '', title: title ?? '' },
233
- }));
234
- }
235
- setQuestionStatus(examId, targetIndex + 1, QuestionStatus.UNSAVED);
236
- if (targetIndex === currentQuestionIndex) {
237
- setCurrentQuestionHasUnsavedChanges(true);
238
- markQuestionCompleted(targetIndex, false);
239
- }
240
- else {
241
- markQuestionCompleted(targetIndex, false);
242
- }
243
- }, [currentPartId, currentQuestionIndex, examId, markQuestionCompleted, setCurrentQuestionHasUnsavedChanges, template, upsertQuestionData]);
244
- const handleExistingQuestionSelect = (question) => {
245
- applyBankItemToIndex(question, currentQuestionIndex);
217
+ setSelectedQuestionInfo((prev) => ({ ...prev, [currentQuestionIndex]: { code, title } }));
218
+ if (bankId && (code || title))
219
+ setExistingQuestionBanks((prev) => ({ ...prev, [currentQuestionIndex]: { id: bankId, code: code ?? '', title: title ?? '' } }));
220
+ setQuestionStatus(examId, currentQuestionIndex + 1, QuestionStatus.UNSAVED);
221
+ setCurrentQuestionHasUnsavedChanges(true);
222
+ markQuestionCompleted(currentQuestionIndex, false);
246
223
  success({ message: t('common.examCreator.bankSelected') });
247
224
  };
248
- const handlePickerConfirm = (items) => {
249
- if (items.length === 0)
250
- return;
251
- items.forEach((item, offset) => {
252
- const targetIndex = Math.min(currentQuestionIndex + offset, template.totalQuestions - 1);
253
- applyBankItemToIndex(item, targetIndex);
254
- });
255
- success({
256
- message: items.length === 1
257
- ? t('common.examCreator.bankSelected')
258
- : `Đã chèn ${items.length} câu từ ngân hàng`,
259
- });
260
- };
261
225
  const handleConfirmSaveExam = () => {
262
226
  setShowCompletionDialog(false);
263
227
  clearQuestionStatuses(examId);
@@ -270,7 +234,7 @@ export function ExamCreator(props) {
270
234
  else
271
235
  setShowCompletionDialog(true);
272
236
  });
273
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50/30", children: [_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "w-80 overflow-y-auto border-r border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamPartTabs, { template: template, currentPartId: currentPartId, completedQuestions: completedQuestions, onPartChange: (partId) => tryNavigate(template.parts.find((part) => part.id === partId)?.startIndex ?? 0) }) }), _jsxs("div", { className: "flex flex-1 flex-col overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-4 pb-4", children: _jsxs("div", { className: "mx-auto max-w-4xl", children: [props.searchQuestions ? (_jsx("div", { className: "mb-3 flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "gap-1.5", onClick: () => setPickerOpen(true), children: [_jsx(Library, { className: "h-4 w-4" }), "Ng\u00E2n h\u00E0ng c\u00E2u h\u1ECFi"] }) })) : null, currentPart?.skill === 'LISTENING' && _jsx(PartAudioUploader, { partId: currentPart.id, partName: currentPart.name, audioUrl: props.partAudioOverrides?.[currentPart.id] ?? currentPart.audioUrl ?? '', examId: examId, onAudioChange: (url) => void props.onPartAudioChange?.(currentPart.id, url), isReadOnly: !props.onPartAudioChange }), _jsx(QuestionCreator, { initialData: currentQuestionDraft, prefilledType: currentConfig?.questionType, prefilledSkill: currentPart?.skill, optionCount: currentConfig?.optionCount, optionMode: currentConfig?.optionMode, defaultValue: currentConfig?.defaultValue, defaultQuestion: currentConfig?.defaultQuestion, viewMode: currentConfig?.viewMode, groups: currentConfig?.groups || normalizedPart?.groups, groupConfig: currentConfig?.groupConfig || normalizedPart?.groupConfig, groupType: currentConfig?.groupType || normalizedPart?.groupType, articleCount: currentConfig?.articleCount ?? currentPart?.articleCount, questionConfig: currentConfig?.questionConfig || currentPart?.questionConfig, questionIndexInPart: isPartLevelGroup ? currentQuestionIndex - (currentPart?.startIndex ?? 0) : currentQuestionIndex - (currentConfig?.startIndex ?? currentPart?.startIndex ?? 0), partId: sharedDataPartId, onChange: handleQuestionDraftChange, onUnsavedChangesChange: setCurrentQuestionHasUnsavedChanges, onSave: handleQuestionSave, onCancel: props.onCancel, isEmbedded: true, externalErrors: validationErrors[currentQuestionIndex], validationRef: validationRef, suggestedAnswers: currentConfig?.suggestedAnswers || currentPart?.suggestedAnswers, hasSuggestedAnswers: currentConfig?.hasSuggestedAnswers ?? currentPart?.hasSuggestedAnswers, headerExtra: _jsx(QuestionSearchDropdown, { questionType: currentConfig?.questionType || currentPart?.questionType, onSelect: handleExistingQuestionSelect, selectedQuestionLabel: selectedQuestionInfo[currentQuestionIndex] ? `${selectedQuestionInfo[currentQuestionIndex].code ? `#${selectedQuestionInfo[currentQuestionIndex].code} - ` : ''}${selectedQuestionInfo[currentQuestionIndex].title || ''}` : undefined, disabled: !(currentConfig?.questionType || currentPart?.questionType), compact: true, searchQuestions: props.searchQuestions }) }, currentQuestionIndex), _jsx("div", { className: "mt-8", children: _jsx(QuestionBankOption, { checked: bankSetting.checked, onCheckedChange: (checked) => updateBankSetting(currentQuestionIndex, { checked }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), code: bankSetting.code, onCodeChange: (code) => updateBankSetting(currentQuestionIndex, { code }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), title: bankSetting.title, onTitleChange: (title) => updateBankSetting(currentQuestionIndex, { title }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), codeError: questionBankCodeError, error: questionBankTitleError, existingBank: existingQuestionBanks[currentQuestionIndex] || null }) })] }) }), _jsx("div", { className: "flex-shrink-0 border-t border-gray-200/80 bg-white/95 shadow-[0_-4px_20px_rgba(0,0,0,0.08)] backdrop-blur-sm", children: _jsxs("div", { className: "mx-auto flex max-w-4xl items-center justify-between px-6 py-4", children: [_jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex - 1), disabled: currentQuestionIndex === 0, className: "gap-2 border-gray-300 hover:bg-gray-50", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), "C\u00E2u tr\u01B0\u1EDBc"] }), _jsxs("div", { className: "flex items-center gap-4", children: [currentQuestionHasUnsavedChanges ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-amber-50 px-3 py-1.5 ring-1 ring-amber-200", children: [_jsx("span", { className: "h-2 w-2 animate-pulse rounded-full bg-amber-500" }), _jsx("span", { className: "text-xs font-medium text-amber-700", children: "Ch\u01B0a l\u01B0u" })] }) : questionsState[currentQuestionIndex]?.isCompleted ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-green-50 px-3 py-1.5 ring-1 ring-green-200", children: [_jsx(CheckCircle, { className: "h-3.5 w-3.5 text-green-500" }), _jsx("span", { className: "text-xs font-medium text-green-700", children: "\u0110\u00E3 l\u01B0u" })] }) : null, _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-gray-100 px-4 py-2", children: [_jsxs("span", { className: "text-sm font-bold text-gray-900", children: ["C\u00E2u ", currentQuestionIndex + 1] }), _jsx("span", { className: "text-sm text-gray-400", children: "/" }), _jsx("span", { className: "text-sm text-gray-500", children: template.totalQuestions })] }), _jsx(Button, { onClick: () => void handleQuestionSave(getQuestionData(currentQuestionIndex)), disabled: isSavingQuestion || !currentQuestionHasUnsavedChanges, className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-md hover:shadow-lg transition-all", children: isSavingQuestion ? _jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "\u0110ang l\u01B0u..."] }) : _jsxs(_Fragment, { children: [_jsx(Save, { className: "h-4 w-4" }), "L\u01B0u c\u00E2u h\u1ECFi"] }) })] }), _jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex + 1), disabled: currentQuestionIndex >= template.totalQuestions - 1 || !questionsState[currentQuestionIndex]?.isCompleted, className: "gap-2 border-gray-300 hover:bg-gray-50", children: ["C\u00E2u ti\u1EBFp theo", _jsx(ChevronRight, { className: "h-4 w-4" })] })] }) })] }), _jsx("div", { className: "w-80 overflow-y-auto border-l border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamQuestionGrid, { template: template, currentQuestionIndex: currentQuestionIndex, completedQuestions: completedQuestions, onQuestionChange: tryNavigate }) })] }), _jsx(CompletionDialog, { open: showCompletionDialog, onOpenChange: setShowCompletionDialog, completedCount: completedQuestions.size, totalQuestions: template.totalQuestions, onConfirm: handleConfirmSaveExam }), _jsx(UnsavedChangesDialog, { open: showUnsavedDialog, onOpenChange: setShowUnsavedDialog, onConfirm: confirmNavigation, onCancel: () => setShowUnsavedDialog(false), questionNumber: currentQuestionIndex + 1 }), props.searchQuestions ? (_jsx(QuestionBankPickerDialog, { open: pickerOpen, onOpenChange: setPickerOpen, searchQuestions: props.searchQuestions, getQuestionFilters: props.getQuestionFilters, defaultFilters: props.questionBankDefaultFilters, lessonSuggestions: props.questionBankLessonSuggestions, onConfirm: handlePickerConfirm })) : null] }));
237
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50/30", children: [_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "w-80 overflow-y-auto border-r border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamPartTabs, { template: template, currentPartId: currentPartId, completedQuestions: completedQuestions, onPartChange: (partId) => tryNavigate(template.parts.find((part) => part.id === partId)?.startIndex ?? 0) }) }), _jsxs("div", { className: "flex flex-1 flex-col overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-4 pb-4", children: _jsxs("div", { className: "mx-auto max-w-4xl", children: [currentPart?.skill === 'LISTENING' && _jsx(PartAudioUploader, { partId: currentPart.id, partName: currentPart.name, audioUrl: props.partAudioOverrides?.[currentPart.id] ?? currentPart.audioUrl ?? '', examId: examId, onAudioChange: (url) => void props.onPartAudioChange?.(currentPart.id, url), isReadOnly: !props.onPartAudioChange }), _jsx(QuestionCreator, { initialData: currentQuestionDraft, prefilledType: currentConfig?.questionType, prefilledSkill: currentPart?.skill, optionCount: currentConfig?.optionCount, optionMode: currentConfig?.optionMode, defaultValue: currentConfig?.defaultValue, defaultQuestion: currentConfig?.defaultQuestion, viewMode: currentConfig?.viewMode, groups: currentConfig?.groups || normalizedPart?.groups, groupConfig: currentConfig?.groupConfig || normalizedPart?.groupConfig, groupType: currentConfig?.groupType || normalizedPart?.groupType, articleCount: currentConfig?.articleCount ?? currentPart?.articleCount, questionConfig: currentConfig?.questionConfig || currentPart?.questionConfig, questionIndexInPart: isPartLevelGroup ? currentQuestionIndex - (currentPart?.startIndex ?? 0) : currentQuestionIndex - (currentConfig?.startIndex ?? currentPart?.startIndex ?? 0), partId: sharedDataPartId, onChange: handleQuestionDraftChange, onUnsavedChangesChange: setCurrentQuestionHasUnsavedChanges, onSave: handleQuestionSave, onCancel: props.onCancel, isEmbedded: true, externalErrors: validationErrors[currentQuestionIndex], validationRef: validationRef, suggestedAnswers: currentConfig?.suggestedAnswers || currentPart?.suggestedAnswers, hasSuggestedAnswers: currentConfig?.hasSuggestedAnswers ?? currentPart?.hasSuggestedAnswers, headerExtra: _jsx(QuestionSearchDropdown, { questionType: currentConfig?.questionType || currentPart?.questionType, onSelect: handleExistingQuestionSelect, selectedQuestionLabel: selectedQuestionInfo[currentQuestionIndex] ? `${selectedQuestionInfo[currentQuestionIndex].code ? `#${selectedQuestionInfo[currentQuestionIndex].code} - ` : ''}${selectedQuestionInfo[currentQuestionIndex].title || ''}` : undefined, disabled: !(currentConfig?.questionType || currentPart?.questionType), compact: true, searchQuestions: props.searchQuestions }) }, currentQuestionIndex), _jsx("div", { className: "mt-8", children: _jsx(QuestionBankOption, { checked: bankSetting.checked, onCheckedChange: (checked) => updateBankSetting(currentQuestionIndex, { checked }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), code: bankSetting.code, onCodeChange: (code) => updateBankSetting(currentQuestionIndex, { code }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), title: bankSetting.title, onTitleChange: (title) => updateBankSetting(currentQuestionIndex, { title }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), codeError: questionBankCodeError, error: questionBankTitleError, existingBank: existingQuestionBanks[currentQuestionIndex] || null }) })] }) }), _jsx("div", { className: "flex-shrink-0 border-t border-gray-200/80 bg-white/95 shadow-[0_-4px_20px_rgba(0,0,0,0.08)] backdrop-blur-sm", children: _jsxs("div", { className: "mx-auto flex max-w-4xl items-center justify-between px-6 py-4", children: [_jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex - 1), disabled: currentQuestionIndex === 0, className: "gap-2 border-gray-300 hover:bg-gray-50", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), "C\u00E2u tr\u01B0\u1EDBc"] }), _jsxs("div", { className: "flex items-center gap-4", children: [currentQuestionHasUnsavedChanges ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-amber-50 px-3 py-1.5 ring-1 ring-amber-200", children: [_jsx("span", { className: "h-2 w-2 animate-pulse rounded-full bg-amber-500" }), _jsx("span", { className: "text-xs font-medium text-amber-700", children: "Ch\u01B0a l\u01B0u" })] }) : questionsState[currentQuestionIndex]?.isCompleted ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-green-50 px-3 py-1.5 ring-1 ring-green-200", children: [_jsx(CheckCircle, { className: "h-3.5 w-3.5 text-green-500" }), _jsx("span", { className: "text-xs font-medium text-green-700", children: "\u0110\u00E3 l\u01B0u" })] }) : null, _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-gray-100 px-4 py-2", children: [_jsxs("span", { className: "text-sm font-bold text-gray-900", children: ["C\u00E2u ", currentQuestionIndex + 1] }), _jsx("span", { className: "text-sm text-gray-400", children: "/" }), _jsx("span", { className: "text-sm text-gray-500", children: template.totalQuestions })] }), _jsx(Button, { onClick: () => void handleQuestionSave(getQuestionData(currentQuestionIndex)), disabled: isSavingQuestion || !currentQuestionHasUnsavedChanges, className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-md hover:shadow-lg transition-all", children: isSavingQuestion ? _jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "\u0110ang l\u01B0u..."] }) : _jsxs(_Fragment, { children: [_jsx(Save, { className: "h-4 w-4" }), "L\u01B0u c\u00E2u h\u1ECFi"] }) })] }), _jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex + 1), disabled: currentQuestionIndex >= template.totalQuestions - 1 || !questionsState[currentQuestionIndex]?.isCompleted, className: "gap-2 border-gray-300 hover:bg-gray-50", children: ["C\u00E2u ti\u1EBFp theo", _jsx(ChevronRight, { className: "h-4 w-4" })] })] }) })] }), _jsx("div", { className: "w-80 overflow-y-auto border-l border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamQuestionGrid, { template: template, currentQuestionIndex: currentQuestionIndex, completedQuestions: completedQuestions, onQuestionChange: tryNavigate }) })] }), _jsx(CompletionDialog, { open: showCompletionDialog, onOpenChange: setShowCompletionDialog, completedCount: completedQuestions.size, totalQuestions: template.totalQuestions, onConfirm: handleConfirmSaveExam }), _jsx(UnsavedChangesDialog, { open: showUnsavedDialog, onOpenChange: setShowUnsavedDialog, onConfirm: confirmNavigation, onCancel: () => setShowUnsavedDialog(false), questionNumber: currentQuestionIndex + 1 })] }));
274
238
  }
275
239
  function buildQuestionForSave(questionData, freshData, existing, questionType, currentPart, currentQuestionIndex, examDifficulty) {
276
240
  return { ...existing, ...questionData, ...(freshData ? { answer: freshData } : {}), type: questionData?.type ?? existing?.type ?? questionType, id: existing?.id ?? questionData?.id, partId: currentPart.id, questionNumber: currentQuestionIndex + 1, difficulty: (questionData?.difficulty ?? examDifficulty ?? existing?.difficulty), skill: currentPart.skill, isCompleted: true };
@@ -1,6 +1,5 @@
1
1
  import type { UserRole } from '../../shared/types/common.types';
2
2
  import type { ExamQuestionsSdkApi } from '../../api/exam-questions.types';
3
- import type { QuestionBankLessonSuggestion, QuestionBankPickerDefaultFilters } from '../questions/question-bank/QuestionBankPickerDialog';
4
3
  import type { CreateExamTexts } from './create-exam.texts';
5
4
  export interface ExamQuestionsPageContainerProps {
6
5
  examId: string;
@@ -10,9 +9,5 @@ export interface ExamQuestionsPageContainerProps {
10
9
  onNavigate?: (href: string) => void;
11
10
  /** Optional label overrides for embedded contexts. */
12
11
  texts?: CreateExamTexts;
13
- /** Prefill book/unit/lesson in bank picker (e.g. homework-context). */
14
- questionBankDefaultFilters?: QuestionBankPickerDefaultFilters;
15
- /** Lesson chips for bank picker cascade. */
16
- questionBankLessonSuggestions?: QuestionBankLessonSuggestion[];
17
12
  }
18
13
  export declare function ExamQuestionsPageContainer(props: ExamQuestionsPageContainerProps): import("react").JSX.Element;
@@ -9,7 +9,7 @@ import { useT } from '../../shared/lib/i18n';
9
9
  import { transformApiQuestionsToFrontend } from '../../shared/lib/utils/question-reverse-transform';
10
10
  import { ExamCreator } from './ExamCreator';
11
11
  import { initializeQuestionStatusesFromAPI } from './exam-question-status';
12
- function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavigate, texts, questionBankDefaultFilters, questionBankLessonSuggestions, }) {
12
+ function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavigate, texts }) {
13
13
  const t = useT();
14
14
  const { error: errorToast, success } = useToast();
15
15
  const saveExamRef = useRef(null);
@@ -78,7 +78,7 @@ function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavig
78
78
  return _jsx(Loading, { label: texts?.questionsLoadingLabel ?? t('admin.exams.questions.loading') });
79
79
  if (!exam || !template)
80
80
  return _jsx(Empty, { onBack: () => navigate(listPath), label: texts?.questionsNoTemplateLabel ?? t('admin.exams.questions.noTemplate') });
81
- return (_jsxs("div", { className: "-m-4 flex h-[calc(100vh-4rem)] flex-col overflow-hidden md:-m-6 md:-mb-10", children: [_jsxs("div", { className: "flex flex-shrink-0 items-center gap-4 border-b border-gray-200/60 bg-white px-4 py-3", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: () => navigate(listPath), className: "h-8 w-8", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsxs("h1", { className: "truncate text-lg font-semibold tracking-tight text-gray-900", children: [exam.name, exam.code && _jsxs(Badge, { variant: "outline", className: "ml-2 align-middle border-blue-200 bg-blue-50 text-blue-700 text-xs font-normal", children: [_jsx(Hash, { className: "mr-0.5 h-3 w-3" }), exam.code] })] }) }), _jsxs(Button, { onClick: () => saveExamRef.current?.(), className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-sm", size: "sm", children: [_jsx(Save, { className: "h-4 w-4" }), texts?.saveQuestionsLabel ?? 'Lưu đề thi'] })] }), _jsx(ExamCreator, { template: template, examId: examId, examDifficulty: exam.difficulty, examLevel: exam.examLevel ?? undefined, subjectId: exam.subject?.id, initialQuestions: questions, onSaveQuestion: api.saveQuestion, onSave: handleSaveExam, onCancel: () => navigate(listPath), onPartAudioChange: api.updatePartAudio ? handlePartAudioChange : undefined, partAudioOverrides: partAudioOverrides, searchQuestions: api.searchQuestions, getQuestionFilters: api.getQuestionFilters, questionBankDefaultFilters: questionBankDefaultFilters, questionBankLessonSuggestions: questionBankLessonSuggestions, saveExamRef: saveExamRef })] }));
81
+ return (_jsxs("div", { className: "-m-4 flex h-[calc(100vh-4rem)] flex-col overflow-hidden md:-m-6 md:-mb-10", children: [_jsxs("div", { className: "flex flex-shrink-0 items-center gap-4 border-b border-gray-200/60 bg-white px-4 py-3", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: () => navigate(listPath), className: "h-8 w-8", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsxs("h1", { className: "truncate text-lg font-semibold tracking-tight text-gray-900", children: [exam.name, exam.code && _jsxs(Badge, { variant: "outline", className: "ml-2 align-middle border-blue-200 bg-blue-50 text-blue-700 text-xs font-normal", children: [_jsx(Hash, { className: "mr-0.5 h-3 w-3" }), exam.code] })] }) }), _jsxs(Button, { onClick: () => saveExamRef.current?.(), className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-sm", size: "sm", children: [_jsx(Save, { className: "h-4 w-4" }), texts?.saveQuestionsLabel ?? 'Lưu đề thi'] })] }), _jsx(ExamCreator, { template: template, examId: examId, examDifficulty: exam.difficulty, examLevel: exam.examLevel ?? undefined, subjectId: exam.subject?.id, initialQuestions: questions, onSaveQuestion: api.saveQuestion, onSave: handleSaveExam, onCancel: () => navigate(listPath), onPartAudioChange: api.updatePartAudio ? handlePartAudioChange : undefined, partAudioOverrides: partAudioOverrides, searchQuestions: api.searchQuestions, saveExamRef: saveExamRef })] }));
82
82
  }
83
83
  export function ExamQuestionsPageContainer(props) {
84
84
  return _jsx(Suspense, { fallback: _jsx(Loading, { label: "Loading..." }), children: _jsx(ExamQuestionsContent, { ...props }) });
@@ -2,4 +2,4 @@ export { buildExamTemplatePayload, createExamCreateApi, createMockCreateExamApi
2
2
  export type { CreateExamFetchApiOptions, CreateExamPayload, CreateExamSdkApi, ExamFormData, ExamLevelOption, ExamResponse, ExamTemplatePayload, UpdateExamTemplateInput } from '../../api/exam-create';
3
3
  export { createExamQuestionsApi, createExamQuestionsFetchApi, createMockExamQuestionsApi } from '../../api/exam-questions';
4
4
  export { createExamTakingApi, createMockExamTakingApi } from '../../api/exam-taking';
5
- export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from '../../api/exam-questions';
5
+ export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from '../../api/exam-questions';
@@ -21,7 +21,7 @@ export { ExamQuestionGrid } from './ExamQuestionGrid';
21
21
  export { PartAudioUploader } from './PartAudioUploader';
22
22
  export type { PartAudioUploaderProps } from './PartAudioUploader';
23
23
  export { createExamQuestionsApi, createExamQuestionsFetchApi, createMockExamQuestionsApi } from './api';
24
- export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './api';
24
+ export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from './api';
25
25
  export { ExamQuestionsPageContainer } from './ExamQuestionsPageContainer';
26
26
  export type { ExamQuestionsPageContainerProps } from './ExamQuestionsPageContainer';
27
27
  export { UnsavedChangesDialog } from './UnsavedChangesDialog';
@@ -20,11 +20,6 @@ interface QuestionRendererProps {
20
20
  onTeacherIntroPlayed?: () => void;
21
21
  /** Index of the question to render within the part (for Cambridge YLE 1-by-1 navigation) */
22
22
  currentQuestionIndex?: number;
23
- /**
24
- * Compact chrome for host embeds (school homework). Currently applied to
25
- * WORD_FILL_STRUCTURED_FORM only — exam take UI stays full banners by default.
26
- */
27
- compactLayout?: boolean;
28
23
  }
29
- export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, compactLayout, }: QuestionRendererProps): import("react").JSX.Element;
24
+ export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }: QuestionRendererProps): import("react").JSX.Element;
30
25
  export {};
@@ -13,7 +13,7 @@ import { SpeakingQuestionRenderer } from './speaking/renderers';
13
13
  import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformWordOrdering, transformWordOrderingGroupTake, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformMatchingWithLinesGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, transformSortWordsIntoCategories, transformChooseThenAnswerGroup, } from '../utils/question-transformers';
14
14
  import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
15
15
  import { normalizeColoringAssignments } from '../utils/coloring-assignments';
16
- export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, compactLayout = false, }) {
16
+ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }) {
17
17
  const { partNo, questionType, questions } = part;
18
18
  const questionCount = questions.filter(q => !q.is_example).length;
19
19
  const getRenderQuestions = (arr) => {
@@ -222,7 +222,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
222
222
  }
223
223
  case 'WORD_FILL_STRUCTURED_FORM': {
224
224
  const data = transformWordFillStructuredForm(questions);
225
- return (_jsx(MoversWordFillStructuredFormRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, groupTitle: data.groupTitle, groupContent: data.groupContent, groupImageUrl: data.groupImageUrl, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, compactLayout: compactLayout }));
225
+ return (_jsx(MoversWordFillStructuredFormRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, groupTitle: data.groupTitle, groupContent: data.groupContent, groupImageUrl: data.groupImageUrl, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
226
226
  }
227
227
  case 'WRITE_SENTENCES': {
228
228
  const data = transformWriteSentences(questions);
@@ -13,12 +13,6 @@ interface MoversWordFillStructuredFormRendererProps {
13
13
  answers: Record<string, any>;
14
14
  onAnswerChange: (questionId: string, value: any) => void;
15
15
  isReviewMode?: boolean;
16
- /**
17
- * Host embeds (e.g. school homework): hide Cambridge part/instruction banners
18
- * and put the rubric inline next to the question number. Default false keeps
19
- * full exam chrome unchanged.
20
- */
21
- compactLayout?: boolean;
22
16
  }
23
- export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode, compactLayout, }: MoversWordFillStructuredFormRendererProps): React.JSX.Element;
17
+ export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode, }: MoversWordFillStructuredFormRendererProps): React.JSX.Element;
24
18
  export {};
@@ -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
  /**
@@ -73,23 +66,21 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
73
66
  const containerRef = useRef(null);
74
67
  const onBlankChangeRef = useRef(onBlankChange);
75
68
  onBlankChangeRef.current = onBlankChange;
76
- // Track the HTML last applied so we re-apply when content changes and
77
- // survive React Strict Mode double-invoke (cleanup may leave empty DOM).
78
- const appliedHtmlRef = useRef(null);
69
+ const mountedRef = useRef(false);
79
70
  // Store initial answers in a ref so they're available on mount but don't trigger re-renders
80
71
  const initialAnswersRef = useRef(initialAnswers);
81
72
  const processedHtml = useMemo(() => buildHtmlWithInputPlaceholders(question.content, question.wordBank), [question.content, question.wordBank]);
82
- // Set innerHTML via ref and attach event listeners.
83
- // Bypasses React reconciliation so inputs keep focus on answer re-renders.
84
- // Re-apply when processedHtml changes OR container is empty (Strict Mode remount).
73
+ // Set innerHTML ONCE via ref and attach event listeners.
74
+ // This completely bypasses React's reconciliation so inputs are never
75
+ // destroyed by re-renders.
85
76
  useEffect(() => {
86
77
  const container = containerRef.current;
87
78
  if (!container)
88
79
  return;
89
- const needsApply = appliedHtmlRef.current !== processedHtml || container.childNodes.length === 0;
90
- if (needsApply) {
80
+ // Only set innerHTML on first mount or if HTML content actually changed
81
+ if (!mountedRef.current) {
91
82
  container.innerHTML = processedHtml;
92
- appliedHtmlRef.current = processedHtml;
83
+ mountedRef.current = true;
93
84
  }
94
85
  const inputs = container.querySelectorAll('input.structured-blank-input, select.structured-blank-input');
95
86
  const handlers = [];
@@ -98,33 +89,33 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
98
89
  const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
99
90
  const hint = container.querySelector(`[data-blank-hint="${key}"]`);
100
91
  const userAnswer = initialAnswersRef.current[key] || '';
101
- const correctAnswer = question.answers[key] || '';
92
+ const correctAnswer = question.answers[key];
93
+ const correctLabel = formatWfsfCorrectLabel(correctAnswer);
102
94
  const hasUserAnswer = userAnswer.trim().length > 0;
103
95
  const isCorrect = hasUserAnswer
104
- && normalizeStructuredAnswer(userAnswer, question.caseSensitive)
105
- === normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
96
+ && isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
106
97
  // Restore saved answer value on mount
107
- const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctAnswer.trim().length > 0;
98
+ const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
108
99
  input.value = shouldReplaceInputWithReviewText
109
- ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctAnswer}`
100
+ ? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctLabel}`
110
101
  : userAnswer;
111
102
  input.disabled = isReviewMode;
112
103
  const isSelect = input.tagName === 'SELECT';
113
104
  // Set classes from blankStyle (letter = square box)
114
105
  const blankStyle = question.blankStyle || 'word';
115
106
  const isLetter = blankStyle === 'letter';
116
- const isPhrase = blankStyle === 'phrase';
107
+ const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
117
108
  if (isLetter && !isSelect && 'maxLength' in input) {
118
- input.maxLength = Math.max(1, (correctAnswer || ' ').trim().length || 1);
109
+ const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
110
+ input.maxLength = Math.max(1, maxAltLen || 1);
119
111
  }
120
112
  input.className = cn('structured-blank-input inline-block bg-slate-50/40 text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isSelect
121
113
  ? 'min-w-[5.5rem] max-w-[10rem] rounded border px-2 py-1 text-center'
122
114
  : isLetter
123
115
  ? 'h-7 w-7 rounded border-2 px-0 text-center uppercase'
124
116
  : 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
117
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5 text-center'
118
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
128
119
  ? isCorrect
129
120
  ? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
130
121
  : hasUserAnswer
@@ -132,7 +123,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
132
123
  : 'border-amber-500 bg-amber-50 text-amber-800 font-bold'
133
124
  : 'border-slate-400 text-slate-800 focus:border-teal-600');
134
125
  if (!isSelect && !isLetter && 'size' in input) {
135
- input.size = isPhrase ? 18 : 10;
126
+ input.size = isPhrase ? 28 : 10;
136
127
  }
137
128
  // letter inline box — host FE may not scan Tailwind h-7/w-7
138
129
  if (isLetter) {
@@ -176,9 +167,9 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
176
167
  }, [processedHtml, isReviewMode]);
177
168
  return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-gray-800", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-gray-200", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-48 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: cn('structured-form-content rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 text-sm leading-relaxed text-gray-800', '[&_.structured-blank-wrapper]:inline-flex [&_.structured-blank-wrapper]:align-baseline [&_.structured-blank-wrapper]:items-baseline [&_.structured-blank-hint]:max-w-[180px]',
178
169
  // 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))) })] })] }))] }));
170
+ '[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] })] }))] }));
180
171
  });
181
- export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, compactLayout = false, }) {
172
+ export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, }) {
182
173
  const answersRef = useRef(answers);
183
174
  answersRef.current = answers;
184
175
  const onAnswerChangeRef = useRef(onAnswerChange);
@@ -196,12 +187,5 @@ export function MoversWordFillStructuredFormRenderer({ partNumber, partName, que
196
187
  });
197
188
  return map;
198
189
  }, [questions, handleBlankChange]);
199
- const rubric = String(instruction || '').trim();
200
- const hasGroupChrome = Boolean(groupTitle || audioUrl || groupImageUrl || groupContent);
201
- return (
202
- // No h-full/overflow-y-auto: homework host embeds this in a card without a
203
- // fixed-height parent; clipping made stems look blank. Exam shell scrolls page.
204
- _jsxs("div", { className: cn('flex flex-col', compactLayout ? 'gap-3' : 'gap-4 sm:gap-6'), children: [!compactLayout && (_jsxs("div", { className: "flex flex-col gap-4 p-3 sm:px-6 sm:pt-2", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode }), groupTitle && (_jsx("h2", { className: "text-center text-xl font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-4 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] })), compactLayout && hasGroupChrome && (_jsxs("div", { className: "flex flex-col gap-3 px-1 sm:px-2", children: [groupTitle && (_jsx("h2", { className: "text-center text-lg font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] })), !compactLayout && questions.length > 0 && (_jsx("div", { className: "px-3 sm:px-6", children: _jsx("hr", { className: "border-t border-slate-200/60 my-1" }) })), _jsx("div", { className: cn('grid grid-cols-1 gap-4 sm:gap-5', compactLayout ? 'p-0' : 'p-3 sm:px-6 sm:pb-6'), children: questions.map((q) => (_jsxs("div", { id: `question-${q.id}`, className: cn('rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-200 hover:shadow-md', compactLayout && 'border-0 p-0 shadow-none hover:border-0 hover:shadow-none'), children: [_jsxs("div", { className: cn('mb-2 flex gap-2.5',
205
- // Single-line rubric: center with badge. Multi-line: align to top of badge.
206
- compactLayout && rubric ? 'items-center' : 'items-start'), children: [_jsx("span", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold leading-none text-white shadow-sm", children: q.questionNumber }), compactLayout && rubric ? (_jsx("p", { className: "min-w-0 flex-1 text-sm font-semibold leading-7 text-slate-800", children: rubric })) : null] }), _jsx(StructuredFormQuestion, { question: q, initialAnswers: answers[q.id] || emptyObj, onBlankChange: blankChangeCallbacks[q.id], isReviewMode: isReviewMode })] }, q.id))) })] }));
190
+ return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar", children: [_jsxs("div", { className: "flex flex-col gap-4 p-3 sm:px-6 sm:pt-2", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode }), groupTitle && (_jsx("h2", { className: "text-center text-xl font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-4 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] }), questions.length > 0 && (_jsx("div", { className: "px-3 sm:px-6", children: _jsx("hr", { className: "border-t border-slate-200/60 my-1" }) })), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:gap-5 p-3 sm:px-6 sm:pb-6", children: questions.map((q) => (_jsxs("div", { id: `question-${q.id}`, className: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-200 hover:shadow-md", children: [_jsx("div", { className: "mb-2 flex items-center gap-2", children: _jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold text-white shadow-sm", children: q.questionNumber }) }), _jsx(StructuredFormQuestion, { question: q, initialAnswers: answers[q.id] || emptyObj, onBlankChange: blankChangeCallbacks[q.id], isReviewMode: isReviewMode })] }, q.id))) })] }));
207
191
  }
@@ -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) => {
@@ -2,13 +2,7 @@ export { ExamTakingPageContainer } from './ExamTakingPageContainer';
2
2
  export type { ExamTakingPageContainerProps } from './ExamTakingPageContainer';
3
3
  export { GenericQuestionRenderer } from './GenericQuestionRenderer';
4
4
  export type { GenericQuestionRendererProps } from './GenericQuestionRenderer';
5
- /** Per-type dispatcher used by ExamTakingPageContainer and host lite flows (BTVN). */
6
- export { QuestionRenderer } from './components/QuestionRenderer';
7
5
  export type { ExamTakingPart } from './exam-taking.utils';
8
- export type { QuestionPart, TemplatePart, TemplateSection, } from './utils/question-transformers';
9
- export { groupQuestionsByPart } from './utils/question-transformers';
10
- /** Normalize UI answer map → API answer payloads (option index wrap, group composites). */
11
- export { transformAnswersForSubmission } from './utils/answer-transformers';
12
6
  export type { ExamTakingAnswerValue, ExamTakingAttempt, ExamTakingQuestion, ExamTakingQuestionsResult, ExamTakingSdkApi, ExamTakingTemplatePart, SaveExamTakingAnswersInput } from '../../../api/exam-taking';
13
7
  export { useExamCountdown } from './hooks/useExamCountdown';
14
8
  export type { UseExamCountdownOptions, UseExamCountdownResult } from './hooks/useExamCountdown';