@tinyweb_dev/oe-exam-sdk 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/components/exams/take/components/question-renderers/MoversTrueFalseRenderer.js +3 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversWriteSentencesRenderer.js +3 -1
  3. package/dist/components/questions/_shared/components/compact/KidCollapsibleBlock.d.ts +8 -0
  4. package/dist/components/questions/_shared/components/compact/KidCollapsibleBlock.js +16 -0
  5. package/dist/components/questions/_shared/components/compact/KidPassage.d.ts +5 -0
  6. package/dist/components/questions/_shared/components/compact/KidPassage.js +21 -0
  7. package/dist/components/questions/_shared/components/compact/KidQuestionCard.js +3 -1
  8. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.d.ts +7 -0
  9. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.js +86 -0
  10. package/dist/components/questions/_shared/components/compact/index.d.ts +4 -0
  11. package/dist/components/questions/_shared/components/compact/index.js +4 -0
  12. package/dist/components/questions/_shared/components/compact/reviewItemCard.d.ts +3 -0
  13. package/dist/components/questions/_shared/components/compact/reviewItemCard.js +11 -0
  14. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +14 -5
  15. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +2 -2
  16. package/dist/components/questions/types/arrange-letters-into-words/ArrangeLettersIntoWordsClient.js +2 -2
  17. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerClient.js +19 -8
  18. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +2 -2
  19. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +36 -56
  20. package/dist/components/questions/types/cross-out-word-group/CrossOutWordGroupClient.js +2 -2
  21. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.js +53 -50
  22. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +2 -2
  23. package/dist/components/questions/types/fill-in-blank-group/FillInBlankAnswerKey.js +8 -2
  24. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +5 -3
  25. package/dist/components/questions/types/find-words-in-matrix/FindWordsInMatrixClient.js +18 -15
  26. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.js +2 -2
  27. package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureGroupClient.js +2 -2
  28. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +2 -2
  29. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +4 -1
  30. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +4 -4
  31. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +17 -82
  32. package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupClient.js +2 -2
  33. package/dist/components/questions/types/true-false-group/TrueFalseGroupClient.js +2 -2
  34. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -23
  35. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +2 -8
  36. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +4 -11
  37. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -9
  38. package/dist/components/questions/types/word-order-and-match-group/WordOrderAndMatchGroupClient.js +4 -2
  39. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +2 -2
  40. package/dist/components/questions/viewer/QuestionViewer.d.ts +3 -1
  41. package/dist/components/questions/viewer/QuestionViewer.js +37 -19
  42. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +1 -1
  43. package/dist/shared/index.d.ts +2 -0
  44. package/dist/shared/index.js +1 -0
  45. package/dist/shared/lib/i18n/messages/en/common.d.ts +38 -0
  46. package/dist/shared/lib/i18n/messages/en/common.js +39 -0
  47. package/dist/shared/lib/i18n/messages/en.d.ts +38 -0
  48. package/dist/shared/lib/i18n/messages/vi/common.d.ts +38 -0
  49. package/dist/shared/lib/i18n/messages/vi/common.js +39 -0
  50. package/dist/shared/lib/i18n/messages/vi.d.ts +38 -0
  51. package/dist/shared/lib/utils/media-urls.d.ts +2 -0
  52. package/dist/shared/lib/utils/media-urls.js +23 -0
  53. package/package.json +1 -1
@@ -1,9 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from 'react';
4
- import { Lightbulb, Mic, Square, Volume2 } from 'lucide-react';
3
+ import { Mic, Volume2 } from 'lucide-react';
5
4
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
- import { KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
5
+ import { KidInstruction, KidPrompt, KidQuestionCard, SpeakingReplyMic, } from '../../_shared/components/compact';
7
6
  function QuestionAudio({ url }) {
8
7
  const { previewUrl, isLoading } = usePresignedFileUrl(url);
9
8
  const src = previewUrl ||
@@ -34,78 +33,6 @@ function findGradeDetail(details, flattenIndex) {
34
33
  item.itemId === `item-${flattenIndex}` ||
35
34
  item.itemId === `item-${flattenIndex + 1}`));
36
35
  }
37
- function SpeakingReplyMic({ flattenIndex, audioKey, disabled, uploading, onRecorded, }) {
38
- const [status, setStatus] = useState('idle');
39
- const [error, setError] = useState('');
40
- const [blobUrl, setBlobUrl] = useState('');
41
- const recorderRef = useRef(null);
42
- const chunksRef = useRef([]);
43
- const streamRef = useRef(null);
44
- useEffect(() => {
45
- return () => {
46
- recorderRef.current?.stop();
47
- streamRef.current?.getTracks().forEach((track) => track.stop());
48
- if (blobUrl) {
49
- URL.revokeObjectURL(blobUrl);
50
- }
51
- };
52
- }, [blobUrl]);
53
- const stopStream = () => {
54
- streamRef.current?.getTracks().forEach((track) => track.stop());
55
- streamRef.current = null;
56
- };
57
- const startRecording = async () => {
58
- if (disabled || uploading || !onRecorded) {
59
- return;
60
- }
61
- setError('');
62
- try {
63
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
64
- streamRef.current = stream;
65
- chunksRef.current = [];
66
- const recorder = new MediaRecorder(stream);
67
- recorderRef.current = recorder;
68
- recorder.ondataavailable = (event) => {
69
- if (event.data.size > 0) {
70
- chunksRef.current.push(event.data);
71
- }
72
- };
73
- recorder.onstop = () => {
74
- const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
75
- setBlobUrl((current) => {
76
- if (current) {
77
- URL.revokeObjectURL(current);
78
- }
79
- return URL.createObjectURL(blob);
80
- });
81
- setStatus('recorded');
82
- stopStream();
83
- onRecorded(flattenIndex, blob);
84
- };
85
- recorder.start();
86
- setStatus('recording');
87
- }
88
- catch {
89
- setError('Không thể mở microphone. Hãy cho phép quyền ghi âm.');
90
- stopStream();
91
- }
92
- };
93
- const stopRecording = () => {
94
- if (recorderRef.current && recorderRef.current.state !== 'inactive') {
95
- recorderRef.current.stop();
96
- }
97
- };
98
- const canRecord = Boolean(onRecorded) && !disabled && !uploading;
99
- return (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-lg border border-dashed border-indigo-200 bg-indigo-50/60 px-4 py-3", children: [canRecord ? (_jsx("button", { type: "button", onClick: status === 'recording' ? stopRecording : startRecording, title: status === 'recording' ? 'Dừng ghi âm' : 'Nhấn micro để trả lời', className: `inline-flex h-12 w-12 items-center justify-center rounded-full text-white shadow-sm transition-colors ${status === 'recording'
100
- ? 'animate-pulse bg-red-500 hover:bg-red-600'
101
- : 'bg-indigo-600 hover:bg-indigo-700'}`, children: status === 'recording' ? _jsx(Square, { className: "h-4 w-4 fill-current" }) : _jsx(Mic, { className: "h-5 w-5" }) })) : (_jsx("div", { className: "inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-300 text-white", children: _jsx(Mic, { className: "h-5 w-5" }) })), _jsx("p", { className: "text-center text-xs text-indigo-700", children: uploading
102
- ? 'Đang tải lên bản ghi...'
103
- : status === 'recording'
104
- ? 'Đang ghi âm... Nhấn để dừng'
105
- : audioKey || status === 'recorded'
106
- ? 'Đã ghi. Nhấn micro để ghi lại'
107
- : 'Nhấn micro để trả lời bằng giọng nói' }), error && _jsx("p", { className: "text-center text-xs text-red-600", children: error }), audioKey ? (_jsx("div", { className: "w-full max-w-sm", children: _jsx(QuestionAudio, { url: audioKey }) })) : status === 'recorded' && blobUrl ? (_jsx("audio", { controls: true, src: blobUrl, className: "h-8 w-full max-w-sm", preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." })) : null] }));
108
- }
109
36
  export function SpontaneousQaGroupClient({ questionData, isReviewMode = false, userAnswer, onItemRecorded, uploadingIndex = null, gradeDetails, disabled = false, }) {
110
37
  const parts = questionData.parts || [];
111
38
  const audioKeys = Array.isArray(userAnswer?.audioKeys) ? userAnswer.audioKeys : [];
@@ -117,12 +44,20 @@ export function SpontaneousQaGroupClient({ questionData, isReviewMode = false, u
117
44
  const detail = findGradeDetail(gradeDetails, currentIndex);
118
45
  const grade = readGradeUser(detail);
119
46
  const showMic = !isReviewMode && !question.isExample;
120
- return (_jsxs(KidQuestionCard, { title: `Câu ${question.questionNumber || questionIndex + 1}`, points: detail?.partialScore != null ? undefined : (question.points || 0), isExample: question.isExample, headerExtra: detail?.partialScore != null ? (_jsxs("span", { className: "text-xs text-gray-500", children: [detail.partialScore, " / ", question.points || 0, " \u0111i\u1EC3m"] })) : undefined, children: [_jsx(KidPrompt, { children: question.questionText || 'Câu hỏi chưa được nhập' }), question.audioUrl ? _jsx(QuestionAudio, { url: question.audioUrl }) : null, showMic && (_jsx(SpeakingReplyMic, { flattenIndex: currentIndex, audioKey: typeof audioKey === 'string' ? audioKey : null, disabled: disabled, uploading: uploadingIndex === currentIndex, onRecorded: onItemRecorded })), detail && !question.isExample && (_jsxs("div", { className: "space-y-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700", children: [grade.transcript && (_jsxs("p", { children: [_jsx("span", { className: "font-semibold", children: "Transcript:" }), " ", grade.transcript] })), (grade.contentScore != null || grade.pronunciationScore != null || grade.fluencyScore != null) && (_jsx("p", { children: [
121
- grade.contentScore != null ? `Content ${Math.round(grade.contentScore)}` : null,
122
- grade.pronunciationScore != null ? `Pronunciation ${Math.round(grade.pronunciationScore)}` : null,
123
- grade.fluencyScore != null ? `Fluency ${Math.round(grade.fluencyScore)}` : null,
124
- ].filter(Boolean).join(' · ') })), grade.feedback && _jsx("p", { children: grade.feedback })] })), isReviewMode && question.expectedAnswers.filter((item) => item.trim() !== '').length > 0 && (_jsxs("div", { className: "rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold text-amber-700", children: [_jsx(Lightbulb, { className: "h-3 w-3" }), "\u0110\u00E1p \u00E1n m\u1EABu"] }), _jsx("ul", { className: "list-disc space-y-1 pl-4 text-xs text-amber-800", children: question.expectedAnswers
125
- .filter((item) => item.trim() !== '')
126
- .map((item) => (_jsx("li", { children: item }, item))) })] }))] }, `${part.partNumber}-${question.questionNumber}-${questionIndex}`));
47
+ const itemNumber = question.questionNumber || questionIndex + 1;
48
+ const scoreLabel = detail?.partialScore != null
49
+ ? `${detail.partialScore} / ${question.points || 0}`
50
+ : null;
51
+ if (isReviewMode) {
52
+ return (_jsxs("div", { className: "flex items-start gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5", children: [_jsx("span", { className: "mt-0.5 w-6 shrink-0 text-sm font-extrabold text-sky-800", children: itemNumber }), _jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx("p", { className: "text-sm font-medium leading-snug text-slate-800", children: question.questionText || 'Câu hỏi chưa được nhập' }), question.audioUrl ? _jsx(QuestionAudio, { url: question.audioUrl }) : null, detail && !question.isExample ? (_jsx("p", { className: "line-clamp-2 text-xs text-slate-600", children: [
53
+ grade.feedback,
54
+ grade.contentScore != null
55
+ ? `Content ${Math.round(grade.contentScore)}`
56
+ : null,
57
+ ]
58
+ .filter(Boolean)
59
+ .join(' · ') })) : null] }), scoreLabel ? (_jsx("span", { className: "shrink-0 text-xs font-semibold text-slate-500", children: scoreLabel })) : null] }, `${part.partNumber}-${question.questionNumber}-${questionIndex}`));
60
+ }
61
+ return (_jsxs(KidQuestionCard, { title: `Câu ${itemNumber}`, points: detail?.partialScore != null ? undefined : (question.points || 0), isExample: question.isExample, headerExtra: detail?.partialScore != null ? (_jsxs("span", { className: "text-xs text-gray-500", children: [detail.partialScore, " / ", question.points || 0, " \u0111i\u1EC3m"] })) : undefined, children: [_jsx(KidPrompt, { children: question.questionText || 'Câu hỏi chưa được nhập' }), question.audioUrl ? _jsx(QuestionAudio, { url: question.audioUrl }) : null, showMic && (_jsx(SpeakingReplyMic, { flattenIndex: currentIndex, audioKey: typeof audioKey === 'string' ? audioKey : null, disabled: disabled, uploading: uploadingIndex === currentIndex, onRecorded: onItemRecorded }))] }, `${part.partNumber}-${question.questionNumber}-${questionIndex}`));
127
62
  })] }, `part-${part.partNumber}-${partIndex}`)))] }));
128
63
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Lightbulb } from 'lucide-react';
4
- import { KidChoiceRow, KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
4
+ import { KidChoiceRow, KidInstruction, KidPrompt, KidQuestionCard, reviewItemPoints, reviewItemTitle } from '../../_shared/components/compact';
5
5
  function emptyAnswer() {
6
6
  return { isTrue: null, correction: '' };
7
7
  }
@@ -25,7 +25,7 @@ export function TrueFalseCorrectGroupClient({ questionData, isReviewMode = false
25
25
  const showStudentCorrection = userValue?.isTrue === false || (isReviewMode && !correctIsTrue);
26
26
  const hasAnswered = userValue?.isTrue === true || userValue?.isTrue === false;
27
27
  const isAnswerCorrect = userValue?.isTrue === correctIsTrue;
28
- return (_jsxs(KidQuestionCard, { title: `Câu ${item.questionNumber || itemIndex + 1}`, points: item.points || 0, isExample: item.isExample, review: isReviewMode && !item.isExample && hasAnswered
28
+ return (_jsxs(KidQuestionCard, { title: reviewItemTitle(item.questionNumber || itemIndex + 1, isReviewMode, `Câu ${item.questionNumber || itemIndex + 1}`), points: reviewItemPoints(isReviewMode, item.points || 0), isExample: item.isExample, review: isReviewMode && !item.isExample && hasAnswered
29
29
  ? (isAnswerCorrect ? 'correct' : 'wrong')
30
30
  : null, children: [_jsx(KidPrompt, { children: item.question || 'Câu hỏi chưa được nhập' }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(KidChoiceRow, { letter: "T", selected: userValue?.isTrue === true, isReviewMode: isReviewMode, isCorrect: isReviewMode && correctIsTrue, disabled: !canSelect, onClick: canSelect
31
31
  ? () => emit(itemIndex, {
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Lightbulb } from 'lucide-react';
4
- import { KidChoiceRow, KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
4
+ import { KidChoiceRow, KidInstruction, KidPrompt, KidQuestionCard, reviewItemPoints, reviewItemTitle } from '../../_shared/components/compact';
5
5
  export function TrueFalseGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
6
6
  const items = questionData.items || [];
7
7
  const handleSelect = (itemIndex, value) => {
@@ -19,7 +19,7 @@ export function TrueFalseGroupClient({ questionData, isReviewMode = false, userA
19
19
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
20
20
  const hasAnswered = userValue !== undefined;
21
21
  const isAnswerCorrect = userValue === correctAnswer;
22
- return (_jsxs(KidQuestionCard, { title: `Câu ${item.questionNumber || itemIndex + 1}`, points: item.points || 0, isExample: item.isExample, review: isReviewMode && !item.isExample && hasAnswered
22
+ return (_jsxs(KidQuestionCard, { title: reviewItemTitle(item.questionNumber || itemIndex + 1, isReviewMode, `Câu ${item.questionNumber || itemIndex + 1}`), points: reviewItemPoints(isReviewMode, item.points || 0), isExample: item.isExample, review: isReviewMode && !item.isExample && hasAnswered
23
23
  ? (isAnswerCorrect ? 'correct' : 'wrong')
24
24
  : null, children: [_jsx(KidPrompt, { children: item.question || 'Câu hỏi chưa được nhập' }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(KidChoiceRow, { letter: "T", selected: userValue === true, isReviewMode: isReviewMode, isCorrect: isReviewMode && correctAnswer === true, disabled: !canSelect, onClick: canSelect ? () => handleSelect(itemIndex, true) : undefined, children: "\u0110\u00FAng" }), _jsx(KidChoiceRow, { letter: "F", selected: userValue === false, isReviewMode: isReviewMode, isCorrect: isReviewMode && correctAnswer === false, disabled: !canSelect, onClick: canSelect ? () => handleSelect(itemIndex, false) : undefined, children: "Sai" })] })] }, `${item.questionNumber}-${itemIndex}`));
25
25
  }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
@@ -2,11 +2,18 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
4
4
  import { Check, X, Library } from 'lucide-react';
5
+ import { useT } from '../../../../shared/lib/i18n';
5
6
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
7
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
6
8
  import { cn } from '../../../../shared/lib/utils';
7
9
  import { KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
8
10
  import { detectWfsfBankMode, isOptionsWordEntry, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
9
11
  import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, primaryWfsfAnswers, } from '../../../../shared/lib/utils/wfsf-blank-match';
12
+ function wfsfFilledText(value) {
13
+ if (Array.isArray(value))
14
+ return String(value[0] ?? '');
15
+ return value == null ? '' : String(value);
16
+ }
10
17
  function normalizeWfsfBlankStyle(raw) {
11
18
  const v = String(raw || '').toLowerCase();
12
19
  if (v === 'letter' || v === 'box')
@@ -61,28 +68,14 @@ function ClientAudioItem({ url }) {
61
68
  return null;
62
69
  return (_jsx("div", { className: "w-full max-w-md", children: _jsx("audio", { controls: true, src: src, className: "w-full h-10 rounded-md" }) }));
63
70
  }
64
- const DEFAULT_WFSF_HEADER = 'Điền từ vào biểu mẫu có cấu trúc';
65
71
  export function WordFillStructuredFormClient({ questionData, isReviewMode = false, userAnswer, onAnswerChange, autoFillCorrectAnswers = false, instruction, questionNumber, explanation, }) {
66
72
  const { title = '', content = '', answers = {}, caseSensitive = false, blankStyle: rawBlankStyle, viewMode, wordBank = [], instruction: dataInstruction, } = questionData;
73
+ const t = useT();
67
74
  const blankStyle = normalizeWfsfBlankStyle(rawBlankStyle);
68
75
  const isLetterBlank = blankStyle === 'letter';
69
76
  const isPhraseBlank = blankStyle === 'phrase' || blankStyle === 'translate';
70
- const allImages = useMemo(() => {
71
- if (Array.isArray(questionData.imageUrls) && questionData.imageUrls.length > 0) {
72
- return questionData.imageUrls.filter(Boolean);
73
- }
74
- if (questionData.imageUrl)
75
- return [questionData.imageUrl];
76
- return [];
77
- }, [questionData.imageUrls, questionData.imageUrl]);
78
- const allAudios = useMemo(() => {
79
- if (Array.isArray(questionData.audioUrls) && questionData.audioUrls.length > 0) {
80
- return questionData.audioUrls.filter(Boolean);
81
- }
82
- if (questionData.audioUrl)
83
- return [questionData.audioUrl];
84
- return [];
85
- }, [questionData.audioUrls, questionData.audioUrl]);
77
+ const allImages = useMemo(() => toMediaUrlList(questionData.imageUrls?.length ? questionData.imageUrls : questionData.imageUrl), [questionData.imageUrls, questionData.imageUrl]);
78
+ const allAudios = useMemo(() => toMediaUrlList(questionData.audioUrls?.length ? questionData.audioUrls : questionData.audioUrl), [questionData.audioUrls, questionData.audioUrl]);
86
79
  const [filledAnswers, setFilledAnswers] = useState(() => {
87
80
  if (userAnswer)
88
81
  return { ...userAnswer };
@@ -133,16 +126,18 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
133
126
  return a.localeCompare(b);
134
127
  });
135
128
  }, [content]);
136
- const allFilled = expectedBlankKeys.every(k => filledAnswers[k]?.trim());
137
- const isAllCorrect = isReviewMode && expectedBlankKeys.every((k) => isWfsfBlankCorrect(filledAnswers[k] || '', answers[k], caseSensitive));
129
+ const allFilled = expectedBlankKeys.every((k) => wfsfFilledText(filledAnswers[k]).trim());
130
+ const isAllCorrect = isReviewMode && expectedBlankKeys.every((k) => isWfsfBlankCorrect(wfsfFilledText(filledAnswers[k]), answers[k], caseSensitive));
138
131
  // Header = page rubric. Pearson preview maps draft.instruction → explanation.
139
132
  const rawExplanation = String(explanation || questionData.explanation || '').trim();
140
133
  const headerInstruction = String(instruction || dataInstruction || rawExplanation || '').trim();
141
134
  // Header reads "Bài x: rubric" — the question type name is never shown.
142
- const numberLabel = typeof questionNumber === 'number' && questionNumber > 0
143
- ? `Bài ${questionNumber}`
135
+ const numberLabel = !isReviewMode &&
136
+ typeof questionNumber === 'number' &&
137
+ questionNumber > 0
138
+ ? t('question.viewer.partN', { n: questionNumber })
144
139
  : '';
145
- const headerTitle = numberLabel ? (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "font-bold", children: [numberLabel, headerInstruction ? ':' : ''] }), headerInstruction ? ` ${headerInstruction}` : ''] })) : (headerInstruction || DEFAULT_WFSF_HEADER);
140
+ const headerTitle = numberLabel ? (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "font-bold", children: [numberLabel, headerInstruction ? ':' : ''] }), headerInstruction ? ` ${headerInstruction}` : ''] })) : (headerInstruction || t('question.viewer.wfsfDefaultTitle'));
146
141
  // Do not re-show the same rubric under "Giải thích".
147
142
  const displayExplanation = rawExplanation && rawExplanation !== headerInstruction ? rawExplanation : '';
148
143
  const handleInputChange = useCallback((key, value) => {
@@ -159,7 +154,7 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
159
154
  setExpandedBlank(null);
160
155
  }, [isReviewMode, onAnswerChange]);
161
156
  const renderBlank = useCallback((key, reactKey) => {
162
- const filled = filledAnswers[key] || '';
157
+ const filled = wfsfFilledText(filledAnswers[key]);
163
158
  const isCorrectFill = isReviewMode && isWfsfBlankCorrect(filled, answers[key], caseSensitive);
164
159
  const isWrongFill = isReviewMode && !!filled && !isCorrectFill;
165
160
  const blankOptions = optionsByKey.get(key);
@@ -15,6 +15,7 @@ import { Textarea } from '../../../../components/ui/textarea';
15
15
  import { Switch } from '../../../../components/ui/switch';
16
16
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
17
17
  import { CompactCreatorHeader, CompactExplanationToggle, CompactFieldCard, CompactPreviewBanner, } from '../../_shared/components/compact';
18
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
18
19
  import { FileUpload } from '../../../../components/ui/file-upload';
19
20
  import { ImagePreview } from '../../../../components/ui/image-preview';
20
21
  import { WordFillStructuredFormClient } from './WordFillStructuredFormClient';
@@ -141,14 +142,7 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
141
142
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
142
143
  const [blankStyle, setBlankStyle] = useState(initialData?.blankStyle ?? 'word');
143
144
  // ── Multiple Images & Audios ────────────────────────────────────────────────
144
- const [imageUrls, setImageUrls] = useState(() => {
145
- if (Array.isArray(initialData?.imageUrls) && initialData.imageUrls.length > 0) {
146
- return initialData.imageUrls.filter(Boolean);
147
- }
148
- if (initialData?.imageUrl)
149
- return [initialData.imageUrl];
150
- return [];
151
- });
145
+ const [imageUrls, setImageUrls] = useState(() => toMediaUrlList(initialData?.imageUrls?.length ? initialData.imageUrls : initialData?.imageUrl));
152
146
  const [audioUrls, setAudioUrls] = useState(() => {
153
147
  if (Array.isArray(initialData?.audioUrls) && initialData.audioUrls.length > 0) {
154
148
  return initialData.audioUrls.filter(Boolean);
@@ -1,3 +1,4 @@
1
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
1
2
  import { normalizeWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
2
3
  import { detectWfsfBankMode, normalizeOptionsWordBank, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
3
4
  function asRecord(value) {
@@ -79,19 +80,11 @@ export function mapQuestionToWfsfData(question) {
79
80
  ? normalizeOptionsWordBank(rawWordBank, blankKeys)
80
81
  : undefined;
81
82
  const groupPayload = asRecord(asRecord(source.questionGroup).payload);
82
- const rawImageUrls = apiContent.imageUrls || answer.imageUrls;
83
- const imageUrls = Array.isArray(rawImageUrls)
84
- ? rawImageUrls.filter(Boolean)
85
- : (apiContent.imageUrl || answer.imageUrl ? [String(apiContent.imageUrl || answer.imageUrl)] : []);
86
- const rawAudioUrls = apiContent.audioUrls || answer.audioUrls;
87
- const audioUrls = Array.isArray(rawAudioUrls)
88
- ? rawAudioUrls.filter(Boolean)
89
- : (apiContent.audioUrl || answer.audioUrl || source.audio_url
90
- ? [String(apiContent.audioUrl || answer.audioUrl || source.audio_url)]
91
- : []);
83
+ const imageUrls = toMediaUrlList(apiContent.imageUrls || answer.imageUrls || apiContent.imageUrl || answer.imageUrl);
84
+ const audioUrls = toMediaUrlList(apiContent.audioUrls || answer.audioUrls || apiContent.audioUrl || answer.audioUrl || source.audio_url);
92
85
  return {
93
86
  title: String(apiContent.title || answer.title || ''),
94
- imageUrl: imageUrls[0] || String(apiContent.imageUrl || answer.imageUrl || ''),
87
+ imageUrl: imageUrls[0] || '',
95
88
  imageUrls,
96
89
  audioUrl: audioUrls[0] || String(apiContent.audioUrl || answer.audioUrl || source.audio_url || ''),
97
90
  audioUrls,
@@ -1,3 +1,4 @@
1
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
1
2
  import { compactWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
2
3
  /**
3
4
  * Transform WORD_FILL_STRUCTURED_FORM creator data → API format
@@ -19,17 +20,11 @@ export const transformWordFillStructuredForm = (question) => {
19
20
  answerData.groupSubtitle ||
20
21
  answerData.groupContent ||
21
22
  answerData.groupImageUrl);
22
- const rawImageUrls = answerData.imageUrls;
23
- const imageUrls = Array.isArray(rawImageUrls)
24
- ? rawImageUrls.filter(Boolean)
25
- : (answerData.imageUrl ? [answerData.imageUrl] : []);
26
- const rawAudioUrls = answerData.audioUrls;
27
- const audioUrls = Array.isArray(rawAudioUrls)
28
- ? rawAudioUrls.filter(Boolean)
29
- : (answerData.audioUrl ? [answerData.audioUrl] : []);
23
+ const imageUrls = toMediaUrlList(answerData.imageUrls ?? answerData.imageUrl);
24
+ const audioUrls = toMediaUrlList(answerData.audioUrls ?? answerData.audioUrl);
30
25
  const apiContent = {
31
26
  title: answerData.title || '',
32
- imageUrl: imageUrls[0] || answerData.imageUrl || '',
27
+ imageUrl: imageUrls[0] || '',
33
28
  imageUrls,
34
29
  audioUrl: audioUrls[0] || answerData.audioUrl || '',
35
30
  audioUrls,
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { X, RotateCcw } from 'lucide-react';
5
5
  import { cn } from '../../../../shared/lib/utils';
6
- import { KidInstruction, KidQuestionCard } from '../../_shared/components/compact';
6
+ import { KidInstruction, KidQuestionCard, reviewItemPoints, reviewItemTitle } from '../../_shared/components/compact';
7
7
  export function WordOrderAndMatchGroupClient({ questionData, isReviewMode = false, userAnswers, onAnswerChange, }) {
8
8
  const { instruction, matchOptions = ['A', 'B', 'C', 'D', 'E', 'F'], items = [] } = questionData;
9
9
  // Initialize interactive state for each item
@@ -152,7 +152,9 @@ export function WordOrderAndMatchGroupClient({ questionData, isReviewMode = fals
152
152
  const isPhraseCorrect = normalizeStr(userPhrase) === normalizeStr(item.orderedPhrase || '');
153
153
  const isLabelCorrect = (userLabel || '').trim().toUpperCase() === (item.matchLabel || '').trim().toUpperCase();
154
154
  const isItemAllCorrect = isPhraseCorrect && isLabelCorrect;
155
- return (_jsx(KidQuestionCard, { title: isExample ? 'Ví dụ (Example)' : `Câu ${item.questionNumber || itemIdx + 1}`, points: !isExample && typeof item.points === 'number' ? item.points : undefined, isExample: isExample, review: isReviewMode && !isExample ? (isItemAllCorrect ? 'correct' : 'wrong') : null, children: _jsxs("div", { className: "grid grid-cols-1 gap-4 lg:grid-cols-12", children: [_jsxs("div", { className: "space-y-3 lg:col-span-8 xl:col-span-9", children: [_jsx("div", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wider", children: "1. S\u1EAFp x\u1EBFp c\u00E1c t\u1EEB th\u00E0nh c\u00E2u ho\u00E0n ch\u1EC9nh:" }), _jsxs("div", { className: cn('min-h-[52px] rounded-xl border-2 border-dashed p-2.5 transition-colors flex flex-wrap items-center gap-1.5', state.placedIndices.length > 0
155
+ return (_jsx(KidQuestionCard, { title: isExample
156
+ ? 'Ví dụ (Example)'
157
+ : reviewItemTitle(item.questionNumber || itemIdx + 1, isReviewMode, `Câu ${item.questionNumber || itemIdx + 1}`), points: reviewItemPoints(isReviewMode, !isExample && typeof item.points === 'number' ? item.points : undefined), isExample: isExample, review: isReviewMode && !isExample ? (isItemAllCorrect ? 'correct' : 'wrong') : null, children: _jsxs("div", { className: "grid grid-cols-1 gap-4 lg:grid-cols-12", children: [_jsxs("div", { className: "space-y-3 lg:col-span-8 xl:col-span-9", children: [_jsx("div", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wider", children: "1. S\u1EAFp x\u1EBFp c\u00E1c t\u1EEB th\u00E0nh c\u00E2u ho\u00E0n ch\u1EC9nh:" }), _jsxs("div", { className: cn('min-h-[52px] rounded-xl border-2 border-dashed p-2.5 transition-colors flex flex-wrap items-center gap-1.5', state.placedIndices.length > 0
156
158
  ? 'border-blue-300 bg-blue-50/40'
157
159
  : 'border-gray-300 bg-gray-50/60'), children: [state.placedIndices.length === 0 ? (_jsx("span", { className: "text-xs italic text-gray-400 select-none", children: "Nh\u1EA5n v\u00E0o c\u00E1c t\u1EEB b\u00EAn d\u01B0\u1EDBi \u0111\u1EC3 gh\u00E9p c\u00E2u..." })) : (state.placedIndices.map((wordIdx, orderIdx) => {
158
160
  const word = item.scrambledWords[wordIdx];
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Lightbulb } from 'lucide-react';
4
4
  import { WordOrderingClient } from '../word-ordering/WordOrderingClient';
5
- import { KidInstruction, KidQuestionCard } from '../../_shared/components/compact';
5
+ import { KidInstruction, KidQuestionCard, reviewItemPoints, reviewItemTitle } from '../../_shared/components/compact';
6
6
  export function WordOrderingGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
7
7
  const items = questionData.items || [];
8
8
  const handleItemSubmit = (itemIndex, ordered) => {
@@ -17,7 +17,7 @@ export function WordOrderingGroupClient({ questionData, isReviewMode = false, us
17
17
  });
18
18
  onAnswerChange(next);
19
19
  };
20
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(KidInstruction, { children: questionData.instruction }), items.map((item, itemIndex) => (_jsx(KidQuestionCard, { title: `Câu ${item.questionNumber || itemIndex + 1}`, points: item.points || 0, isExample: item.isExample, children: _jsx(WordOrderingClient, { questionData: {
20
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx(KidInstruction, { children: questionData.instruction }), items.map((item, itemIndex) => (_jsx(KidQuestionCard, { title: reviewItemTitle(item.questionNumber || itemIndex + 1, isReviewMode, `Câu ${item.questionNumber || itemIndex + 1}`), points: reviewItemPoints(isReviewMode, item.points || 0), isExample: item.isExample, children: _jsx(WordOrderingClient, { questionData: {
21
21
  words: item.words,
22
22
  answer: item.answer,
23
23
  isExample: item.isExample,
@@ -12,6 +12,8 @@ interface QuestionViewerProps {
12
12
  partialScore?: number;
13
13
  feedback?: string;
14
14
  }>;
15
+ onItemRecorded?: (flattenIndex: number, blob: Blob) => void;
16
+ uploadingIndex?: number | null;
15
17
  }
16
- export declare function QuestionViewer({ question, onSubmitAnswer, isReviewMode, userAnswer, gradeDetails, }: QuestionViewerProps): import("react").JSX.Element;
18
+ export declare function QuestionViewer({ question, onSubmitAnswer, isReviewMode, userAnswer, gradeDetails, onItemRecorded, uploadingIndex, }: QuestionViewerProps): import("react").JSX.Element;
17
19
  export {};
@@ -6,7 +6,7 @@ import { Input } from '../../../components/ui/input';
6
6
  import { Check, Volume2, X } from 'lucide-react';
7
7
  import { useRef, useState } from 'react';
8
8
  import { toast } from 'sonner';
9
- import { t } from '../../../shared/lib/i18n';
9
+ import { t, useT } from '../../../shared/lib/i18n';
10
10
  import { ReadAndColorObjectsClient } from '../types/read-and-color-objects/ReadAndColorObjectsClient';
11
11
  import { ArrangeLettersIntoWordsClient } from '../types/arrange-letters-into-words/ArrangeLettersIntoWordsClient';
12
12
  import { WriteShortParagraphClient } from '../types/write-short-paragraph/WriteShortParagraphClient';
@@ -38,7 +38,14 @@ import { MatchWordToPictureViewer } from './MatchWordToPictureViewer';
38
38
  import { MatchWordToPictureGroupViewer } from './MatchWordToPictureGroupViewer';
39
39
  import { MatchColumnsToMakeSentencesViewer } from './MatchColumnsToMakeSentencesViewer';
40
40
  import { WordOrderingGroupViewer } from './WordOrderingGroupViewer';
41
- export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false, userAnswer, gradeDetails, }) {
41
+ function readCueAudioKey(value) {
42
+ if (!value || typeof value !== 'object' || Array.isArray(value))
43
+ return null;
44
+ const key = value.audioKey;
45
+ return typeof key === 'string' && key.trim() !== '' ? key : null;
46
+ }
47
+ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false, userAnswer, gradeDetails, onItemRecorded, uploadingIndex = null, }) {
48
+ const translate = useT();
42
49
  const [selectedAnswer, setSelectedAnswer] = useState(userAnswer || null);
43
50
  const [playCount, setPlayCount] = useState(0);
44
51
  const audioRef = useRef(null);
@@ -214,6 +221,7 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
214
221
  next[index] = value;
215
222
  setFillBlankAnswers(next);
216
223
  setSelectedAnswer(next);
224
+ onSubmitAnswer?.(next);
217
225
  };
218
226
  const isCorrect = (index, value) => {
219
227
  const correctAnswer = blanks[index] ?? '';
@@ -517,11 +525,13 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
517
525
  case 'ANSWER_THE_QUESTION_GROUP':
518
526
  return (_jsx(AnswerTheQuestionGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
519
527
  case 'SPEAKING_CUE_CARD':
520
- return (_jsx(SpeakingCueCardClient, { questionData: mapQuestionToSpeakingCueCardData(question), isReviewMode: isReviewMode }));
528
+ return (_jsx(SpeakingCueCardClient, { questionData: mapQuestionToSpeakingCueCardData(question), isReviewMode: isReviewMode, audioKey: readCueAudioKey(userAnswer), uploading: uploadingIndex === 0, onRecorded: isReviewMode || !onItemRecorded
529
+ ? undefined
530
+ : (blob) => onItemRecorded(0, blob) }));
521
531
  case 'SPONTANEOUS_QA':
522
532
  return (_jsx(SpontaneousQAClient, { questionData: mapQuestionToSpontaneousQAData(question), isReviewMode: isReviewMode }));
523
533
  case 'SPONTANEOUS_QA_GROUP':
524
- return (_jsx(SpontaneousQaGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, disabled: !onSubmitAnswer }));
534
+ return (_jsx(SpontaneousQaGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, disabled: !onSubmitAnswer && !onItemRecorded }));
525
535
  case 'WORD_ORDER_AND_MATCH_GROUP':
526
536
  case 'WORD_ORDER_AND_MATCH':
527
537
  return (_jsx(WordOrderAndMatchGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
@@ -572,7 +582,7 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
572
582
  case 'CHOOSE_THEN_ANSWER_GROUP':
573
583
  return (_jsx(ChooseThenAnswerGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, onSubmitAnswer: onSubmitAnswer }));
574
584
  default:
575
- return _jsxs("div", { children: ["Lo\u1EA1i c\u00E2u h\u1ECFi kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3: ", question.type] });
585
+ return _jsx("div", { children: t('question.viewer.unsupported', { type: question.type }) });
576
586
  }
577
587
  };
578
588
  /**
@@ -588,25 +598,32 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
588
598
  question.type === 'ANSWER_THE_QUESTION') {
589
599
  return null;
590
600
  }
591
- // Modern fill stems live entirely in the body; title is a short label.
592
- if (question.type === 'FILL_IN_BLANK' ||
593
- question.type === 'WORD_FILL_PARAGRAPH') {
594
- return 'Câu hỏi';
595
- }
601
+ const typeKey = `question.type.${question.type}`;
602
+ const typeLabel = translate(typeKey);
603
+ const typedTitle = typeLabel === typeKey ? translate('question.viewer.fallbackTitle') : typeLabel;
596
604
  if (typeof question.content === 'string') {
597
- return stripLightweightMarkdown(question.content) || 'Câu hỏi';
605
+ return stripLightweightMarkdown(question.content) || typedTitle;
598
606
  }
599
607
  const c = question.content;
600
- const raw = c?.title || c?.question || c?.paragraph || '';
601
- // Prefer explicit title; if question/paragraph is stem-like, avoid raw dump.
602
- if (c?.title)
603
- return stripLightweightMarkdown(c.title) || 'Câu hỏi';
608
+ const metaTitle = typeof c?.meta?.title === 'string' ? c.meta.title : '';
609
+ const explicitTitle = (typeof c?.title === 'string' && c.title) || metaTitle;
610
+ if (explicitTitle && explicitTitle.length <= 80 && !/\{\d+\}/.test(explicitTitle) && !/\*\*/.test(explicitTitle)) {
611
+ return stripLightweightMarkdown(explicitTitle) || typedTitle;
612
+ }
613
+ // Modern fill stems live entirely in the body; title is a short type label.
614
+ if (question.type === 'FILL_IN_BLANK' ||
615
+ question.type === 'WORD_FILL_PARAGRAPH') {
616
+ return typedTitle;
617
+ }
618
+ const raw = c?.question || c?.paragraph || '';
604
619
  if (typeof raw === 'string' && (/\{\d+\}/.test(raw) || /\*\*/.test(raw))) {
605
- return 'Câu hỏi';
620
+ return typedTitle;
606
621
  }
607
- return stripLightweightMarkdown(raw) || 'Câu hỏi';
622
+ const stripped = stripLightweightMarkdown(typeof raw === 'string' ? raw : '');
623
+ return stripped && stripped.length <= 80 ? stripped : typedTitle;
608
624
  };
609
- const cardTitle = resolveCardTitle();
625
+ const isGroupQuestion = question.type.endsWith('_GROUP') || Boolean(question.isGroup);
626
+ const cardTitle = isGroupQuestion ? null : resolveCardTitle();
610
627
  // WFSF and Crossword already have their own bordered shell — avoid double Card border.
611
628
  if (question.type === 'WORD_FILL_STRUCTURED_FORM' ||
612
629
  question.type === 'CROSSWORD_PUZZLE' ||
@@ -614,7 +631,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
614
631
  question.type === 'ANSWER_THE_QUESTION') {
615
632
  return (_jsx("div", { className: "space-y-4", children: renderQuestion() }));
616
633
  }
617
- return (_jsxs(Card, { children: [cardTitle ? (_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center justify-between", children: [_jsx("span", { className: "min-w-0 pr-2 text-base font-semibold leading-snug", children: cardTitle }), _jsxs("span", { className: "shrink-0 text-sm font-normal text-gray-500", children: [question.points, " \u0111i\u1EC3m"] })] }) })) : null, _jsx(CardContent, { className: cardTitle ? 'space-y-4' : 'space-y-4 pt-4', children: renderQuestion() })] }));
634
+ const showViewerHeader = Boolean(cardTitle) && !isReviewMode;
635
+ return (_jsxs(Card, { children: [showViewerHeader ? (_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center justify-between", children: [_jsx("span", { className: "min-w-0 pr-2 text-base font-semibold leading-snug", children: cardTitle }), _jsx("span", { className: "shrink-0 text-sm font-normal text-gray-500", children: translate('question.viewer.points', { count: question.points }) })] }) })) : null, _jsx(CardContent, { className: showViewerHeader ? 'space-y-4' : 'space-y-4 pt-4', children: renderQuestion() })] }));
618
636
  }
619
637
  /** Drop lightweight markdown markers for plain header labels (not full token parse). */
620
638
  function stripLightweightMarkdown(text) {
@@ -15,5 +15,5 @@ function readStudentAnswer(value) {
15
15
  };
16
16
  }
17
17
  export function SpontaneousQaGroupViewer({ question, isReviewMode = false, userAnswer, onItemRecorded, uploadingIndex, gradeDetails, disabled, }) {
18
- return (_jsx(SpontaneousQaGroupClient, { questionData: mapQuestionToSpontaneousQaGroupData(question), isReviewMode: isReviewMode, userAnswer: readStudentAnswer(userAnswer), onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, gradeDetails: isReviewMode ? undefined : gradeDetails, disabled: disabled }));
18
+ return (_jsx(SpontaneousQaGroupClient, { questionData: mapQuestionToSpontaneousQaGroupData(question), isReviewMode: isReviewMode, userAnswer: readStudentAnswer(userAnswer), onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, gradeDetails: gradeDetails, disabled: disabled }));
19
19
  }
@@ -3,6 +3,8 @@ export * from './constants/QueryKeyConstant';
3
3
  export * from './constants/exam-level.enum';
4
4
  export * from './constants/routes';
5
5
  export * from './lib/utils';
6
+ export { useLocaleStore } from './lib/stores/localeStore';
7
+ export type { Locale as ExamSdkLocale } from './lib/stores/localeStore';
6
8
  export * from '../components/questions/_shared';
7
9
  export * from './types';
8
10
  export * from './lib/utils/question-transform';
@@ -3,6 +3,7 @@ export * from './constants/QueryKeyConstant';
3
3
  export * from './constants/exam-level.enum';
4
4
  export * from './constants/routes';
5
5
  export * from './lib/utils';
6
+ export { useLocaleStore } from './lib/stores/localeStore';
6
7
  export * from '../components/questions/_shared';
7
8
  export * from './types';
8
9
  export * from './lib/utils/question-transform';
@@ -120,5 +120,43 @@ declare const common: {
120
120
  readonly 'common.badge.source.createdByAdmin': "Created by admin";
121
121
  readonly 'common.badge.source.createdByTeacher': "Created by teacher";
122
122
  readonly 'common.badge.source.createdFromEBM': "Synced from EBM";
123
+ readonly 'question.viewer.fallbackTitle': "Question";
124
+ readonly 'question.viewer.points': "{count} pts";
125
+ readonly 'question.viewer.questionN': "Question {n}";
126
+ readonly 'question.viewer.partN': "Part {n}";
127
+ readonly 'question.viewer.example': "Example";
128
+ readonly 'question.viewer.correct': "Correct";
129
+ readonly 'question.viewer.wrong': "Incorrect";
130
+ readonly 'question.viewer.readingPassage': "Reading passage";
131
+ readonly 'question.viewer.placeholder': "Enter your answer…";
132
+ readonly 'question.viewer.yourAnswer': "Your answer";
133
+ readonly 'question.viewer.writePlaceholder': "Write your answer here…";
134
+ readonly 'question.viewer.answerKey': "Answer key";
135
+ readonly 'question.viewer.noAnswer': "No answer yet";
136
+ readonly 'question.viewer.notWritten': "Not written";
137
+ readonly 'question.viewer.sample': "Sample";
138
+ readonly 'question.viewer.chooseScore': "Choose {score}";
139
+ readonly 'question.viewer.writeScore': "Write {score}";
140
+ readonly 'question.viewer.expandPassage': "Show more";
141
+ readonly 'question.viewer.collapsePassage': "Show less";
142
+ readonly 'question.viewer.unsupported': "Unsupported question type: {type}";
143
+ readonly 'question.viewer.missingPrompt': "Question text is missing";
144
+ readonly 'question.viewer.imageAlt': "Image {n}";
145
+ readonly 'question.viewer.itemImageAlt': "Question {n} image {m}";
146
+ readonly 'question.viewer.atqgDefaultInstruction': "Read the text and answer the questions.";
147
+ readonly 'question.viewer.wfsfDefaultTitle': "Fill in the structured form";
148
+ readonly 'question.type.ANSWER_THE_QUESTION_GROUP': "Answer the questions";
149
+ readonly 'question.type.CHOOSE_THEN_ANSWER_GROUP': "Choose then answer";
150
+ readonly 'question.type.FILL_IN_BLANK': "Fill in the blank";
151
+ readonly 'question.type.FILL_IN_BLANK_GROUP': "Fill in the blanks";
152
+ readonly 'question.type.WORD_FILL_STRUCTURED_FORM': "Structured form";
153
+ readonly 'question.type.WORD_FILL_PARAGRAPH': "Fill the paragraph";
154
+ readonly 'question.type.MATCHING_WITH_LINES_GROUP': "Match the columns";
155
+ readonly 'question.type.MATCH_WORD_TO_PICTURE': "Match words to pictures";
156
+ readonly 'question.type.MATCH_WORD_TO_PICTURE_GROUP': "Match words to pictures";
157
+ readonly 'question.type.CROSSWORD_PUZZLE': "Crossword";
158
+ readonly 'question.type.FIND_WORDS_IN_MATRIX': "Find the words";
159
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER': "Choose the correct answer";
160
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': "Choose the correct answers";
123
161
  };
124
162
  export default common;