@tinyweb_dev/oe-exam-sdk 0.2.7 → 0.2.8

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.
@@ -55,7 +55,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
55
55
  }
56
56
  case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
57
57
  const data = transformChooseTheCorrectAnswerGroup(questions);
58
- return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
58
+ return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
59
59
  }
60
60
  case 'CHOOSE_CORRECT_ADJECTIVE': {
61
61
  const data = transformChooseCorrectAdjective(questions);
@@ -42,7 +42,7 @@ function normalizeRenderableText(value) {
42
42
  }
43
43
  // Example Section Component
44
44
  function ExampleSection({ example }) {
45
- return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
45
+ return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), example.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: example.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
46
46
  const isCorrect = option.id === example.correctOptionId;
47
47
  return (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-40 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", isCorrect
48
48
  ? "bg-green-500 border-green-500 text-white"
@@ -99,7 +99,7 @@ function QuestionItem({ question, selectedOptionIds, onSelect, isReviewMode, id,
99
99
  }
100
100
  return selectedOptionIds.includes(optionId) || correctOptionIds.includes(optionId);
101
101
  };
102
- return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
102
+ return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
103
103
  ? "bg-blue-600 border-blue-600 text-white"
104
104
  : "border-gray-300 bg-white text-transparent"), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }) })] }, option.id))) })) : (_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: normalizeRenderableText(option.text) }), _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 ${isMultipleAnswers ? 'rounded-md' : 'rounded-full'} ${getIndicatorStyle(option.id)}`, children: showIndicatorMark(option.id) && (_jsx("div", { className: `${isMultipleAnswers ? 'h-2.5 w-2.5 rounded-sm' : 'h-2 w-2 rounded-full'} bg-white` })) })] }, option.id))) }))] }));
105
105
  }
@@ -161,6 +161,7 @@ export declare function transformChooseTheCorrectAnswerGroup(questions: ApiQuest
161
161
  questions: ChooseBestAnswerQuestion[];
162
162
  example?: import('../types').ChooseBestAnswerExample;
163
163
  instruction: string;
164
+ audioUrl?: string;
164
165
  };
165
166
  export interface ChooseAdjectiveOption {
166
167
  id: string;
@@ -411,12 +411,18 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
411
411
  }
412
412
  return urls.length === 1 ? urls[0] : urls;
413
413
  };
414
+ const toAudioUrl = (value) => {
415
+ const trimmed = typeof value === 'string' ? value.trim() : '';
416
+ return trimmed !== '' ? trimmed : undefined;
417
+ };
418
+ const groupAudioUrl = toAudioUrl(content?.meta?.audioUrl);
414
419
  let example;
415
420
  if (exampleItem) {
416
421
  const exampleIndex = items.indexOf(exampleItem);
417
422
  const exOptions = mapOptions(exampleItem.content?.options, `${parent.id}-ex`);
418
423
  const exCorrect = exampleItem.correctAnswer?.answer || parentCorrect[exampleIndex] || exOptions[0]?.id || '';
419
424
  const exampleImageUrl = toQuestionImageUrl(exampleItem.content?.imageUrl);
425
+ const exampleAudioUrl = !groupAudioUrl ? toAudioUrl(exampleItem.content?.audioUrl) : undefined;
420
426
  example = {
421
427
  questionNumber: exampleItem.questionNumber || exampleIndex + 1,
422
428
  questionText: exampleItem.content?.question || '',
@@ -424,6 +430,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
424
430
  correctOptionId: exCorrect,
425
431
  optionType: (exampleItem.content?.optionType || 'text'),
426
432
  ...(exampleImageUrl !== undefined ? { imageUrl: exampleImageUrl } : {}),
433
+ ...(exampleAudioUrl ? { audioUrl: exampleAudioUrl } : {}),
427
434
  };
428
435
  }
429
436
  const transformed = regularItems.map((item) => {
@@ -431,6 +438,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
431
438
  const options = mapOptions(item.content?.options, `${parent.id}-item-${itemIndex}`);
432
439
  const correctId = item.correctAnswer?.answer || parentCorrect[itemIndex] || '';
433
440
  const itemImageUrl = toQuestionImageUrl(item.content?.imageUrl);
441
+ const itemAudioUrl = !groupAudioUrl ? toAudioUrl(item.content?.audioUrl) : undefined;
434
442
  return {
435
443
  id: `${parent.id}-item-${itemIndex}`,
436
444
  questionNumber: item.questionNumber || itemIndex + 1,
@@ -440,12 +448,14 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
440
448
  correctOptionIds: correctId ? [correctId] : [],
441
449
  optionType: (item.content?.optionType || 'text'),
442
450
  ...(itemImageUrl !== undefined ? { imageUrl: itemImageUrl } : {}),
451
+ ...(itemAudioUrl ? { audioUrl: itemAudioUrl } : {}),
443
452
  };
444
453
  });
445
454
  return {
446
455
  questions: transformed,
447
456
  example,
448
457
  instruction: content?.meta?.instruction || 'Choose the correct answer.',
458
+ ...(groupAudioUrl ? { audioUrl: groupAudioUrl } : {}),
449
459
  };
450
460
  }
451
461
  export function transformChooseCorrectAdjective(questions) {
@@ -18,10 +18,12 @@ export interface ChooseTheCorrectAnswerGroupItemData {
18
18
  points: number;
19
19
  questionNumber: number;
20
20
  imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
21
+ audioUrl?: string;
21
22
  }
22
23
  export interface ChooseTheCorrectAnswerGroupData {
23
24
  instruction: string;
24
25
  passage?: string;
26
+ audioUrl?: string;
25
27
  items: ChooseTheCorrectAnswerGroupItemData[];
26
28
  explanation?: string;
27
29
  points?: number;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, Check, CircleHelp, Lightbulb, Star } from 'lucide-react';
3
+ import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2 } from 'lucide-react';
4
4
  import { cn } from '../../../../shared/lib/utils';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
@@ -13,6 +13,15 @@ function ClientImageItem({ url, alt }) {
13
13
  }
14
14
  return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
15
15
  }
16
+ function ClientAudio({ url }) {
17
+ const { previewUrl, isLoading } = usePresignedFileUrl(url);
18
+ const src = previewUrl ||
19
+ (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
20
+ if (!src && !isLoading) {
21
+ return null;
22
+ }
23
+ return (_jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-indigo-600" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-indigo-100" })) : (_jsx("audio", { controls: true, className: "h-9 w-full", src: src, preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
24
+ }
16
25
  const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
17
26
  export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
18
27
  const items = questionData.items || [];
@@ -25,10 +34,11 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
25
34
  next[itemIndex] = optionId;
26
35
  onAnswerChange(next);
27
36
  };
28
- return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
37
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && _jsx(ClientAudio, { url: questionData.audioUrl }), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
29
38
  const selectedId = userAnswers[itemIndex];
30
39
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
31
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
40
+ const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
41
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), showItemAudio && item.audioUrl && _jsx(ClientAudio, { url: item.audioUrl }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
32
42
  const isCorrect = option.id === item.correctAnswer;
33
43
  const isSelected = option.id === selectedId;
34
44
  const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
@@ -8,6 +8,7 @@ import { Textarea } from '../../../../components/ui/textarea';
8
8
  import { PointsInput } from '../../../../components/ui/points-input';
9
9
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
10
  import { Switch } from '../../../../components/ui/switch';
11
+ import { FileUpload } from '../../../../components/ui/file-upload';
11
12
  import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
12
13
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
13
14
  import { Eye, ImageIcon, Pencil, Plus, Star, Trash2, Type } from 'lucide-react';
@@ -51,6 +52,7 @@ function normalizeItems(rawItems) {
51
52
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
52
53
  questionNumber: item.questionNumber || index + 1,
53
54
  ...(imageUrl !== undefined ? { imageUrl } : {}),
55
+ ...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
54
56
  };
55
57
  });
56
58
  }
@@ -67,6 +69,7 @@ function parsePointsValue(raw, allowZero) {
67
69
  export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
68
70
  const [instruction, setInstruction] = useState(initialData?.instruction || 'Choose the correct answer.');
69
71
  const [passage, setPassage] = useState(initialData?.passage || '');
72
+ const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
70
73
  const [items, setItems] = useState(() => normalizeItems(initialData?.items));
71
74
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
72
75
  const [isClientMode, setIsClientMode] = useState(false);
@@ -77,9 +80,10 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
77
80
  onChangeRef.current = onChange;
78
81
  }, [onChange]);
79
82
  const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
80
- const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
83
+ const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl) => ({
81
84
  instruction: nextInstruction,
82
85
  ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
86
+ ...(nextAudioUrl.trim() ? { audioUrl: nextAudioUrl.trim() } : {}),
83
87
  items: nextItems,
84
88
  explanation: nextExplanation,
85
89
  points: sumPoints(nextItems),
@@ -96,7 +100,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
96
100
  previousPayloadRef.current = serialized;
97
101
  onUnsavedChangesChange?.(true);
98
102
  debouncedOnChange(payload);
99
- }, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
103
+ }, [instruction, passage, audioUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
100
104
  const validate = () => {
101
105
  const nextErrors = [];
102
106
  if (!instruction.trim()) {
@@ -161,7 +165,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
161
165
  return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EC9nh s\u1EEDa"] }) }), _jsx(ChooseTheCorrectAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
162
166
  }
163
167
  const allErrors = [...errors, ...(externalErrors || [])];
164
- return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
168
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsx("div", { className: "space-y-2", children: _jsx(FileUpload, { id: "choose-answer-group-audio", label: "Audio chung (tu\u1EF3 ch\u1ECDn)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
165
169
  isExample: checked,
166
170
  points: checked ? 0 : (item.points || 1),
167
171
  }) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "A place for someone to stay or live is called" })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { className: "flex items-center gap-2", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-teal-500" }), "H\u00ECnh \u1EA3nh (tu\u1EF3 ch\u1ECDn)"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
@@ -8,6 +8,13 @@ function asRecord(value) {
8
8
  function asString(value, fallback = '') {
9
9
  return typeof value === 'string' ? value : fallback;
10
10
  }
11
+ function asOptionalUrl(value) {
12
+ if (typeof value !== 'string') {
13
+ return undefined;
14
+ }
15
+ const trimmed = value.trim();
16
+ return trimmed !== '' ? trimmed : undefined;
17
+ }
11
18
  export function createEmptyChooseAnswerGroupItem(questionNumber) {
12
19
  return {
13
20
  question: '',
@@ -51,6 +58,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
51
58
  ];
52
59
  const isExample = record.isExample === true;
53
60
  const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
61
+ const audioUrl = asOptionalUrl(content.audioUrl);
54
62
  return {
55
63
  question: asString(content.question) || asString(record.question),
56
64
  optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
@@ -60,6 +68,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
60
68
  points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
61
69
  questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
62
70
  ...(imageUrl !== undefined ? { imageUrl } : {}),
71
+ ...(audioUrl ? { audioUrl } : {}),
63
72
  };
64
73
  }
65
74
  export function mapQuestionToChooseAnswerGroupData(question) {
@@ -73,18 +82,22 @@ export function mapQuestionToChooseAnswerGroupData(question) {
73
82
  : [];
74
83
  if (Array.isArray(content.items)) {
75
84
  const items = content.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index]));
85
+ const audioUrl = asOptionalUrl(asRecord(content.meta).audioUrl);
76
86
  return {
77
87
  instruction: asString(asRecord(content.meta).instruction) || 'Choose the correct answer.',
78
88
  passage: asString(asRecord(content.meta).passage),
89
+ ...(audioUrl ? { audioUrl } : {}),
79
90
  items,
80
91
  explanation,
81
92
  points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
82
93
  };
83
94
  }
84
95
  if (Array.isArray(answer.items)) {
96
+ const audioUrl = asOptionalUrl(answer.audioUrl);
85
97
  return {
86
98
  instruction: asString(answer.instruction, 'Choose the correct answer.'),
87
99
  passage: asString(answer.passage),
100
+ ...(audioUrl ? { audioUrl } : {}),
88
101
  items: answer.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index])),
89
102
  explanation,
90
103
  points: typeof answer.points === 'number' ? answer.points : undefined,
@@ -26,6 +26,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
26
26
  }));
27
27
  const correctAnswer = item.correctAnswer || options[0]?.id || 'opt-1';
28
28
  const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.imageUrl));
29
+ const audioUrl = typeof item.audioUrl === 'string' ? item.audioUrl.trim() : '';
29
30
  return {
30
31
  questionType: CHILD_QUESTION_TYPE,
31
32
  ...(item.isExample ? { isExample: true } : {}),
@@ -34,6 +35,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
34
35
  options,
35
36
  optionType: item.optionType || 'text',
36
37
  ...(imageUrl !== undefined ? { imageUrl } : {}),
38
+ ...(audioUrl ? { audioUrl } : {}),
37
39
  },
38
40
  correctAnswer: { answer: correctAnswer },
39
41
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -46,6 +48,9 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
46
48
  if (answerData?.passage && answerData.passage.trim()) {
47
49
  meta.passage = answerData.passage.trim();
48
50
  }
51
+ if (answerData?.audioUrl && answerData.audioUrl.trim()) {
52
+ meta.audioUrl = answerData.audioUrl.trim();
53
+ }
49
54
  return {
50
55
  apiContent: {
51
56
  meta,
@@ -43,7 +43,7 @@ function ReviewQuestionItem({ question, selectedOptionIds, isCorrect, correctOpt
43
43
  }
44
44
  return _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 border-gray-300 bg-white ${shapeClass}` });
45
45
  };
46
- return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
46
+ return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
47
47
  /* Options — text variant */
48
48
  _jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: option.text }), renderIndicator(option.id)] }, option.id))) }))] }));
49
49
  }
@@ -40,7 +40,7 @@ export function renderDedicatedReviewBody(ctx) {
40
40
  }
41
41
  case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
42
42
  const data = transformChooseTheCorrectAnswerGroup(apiQuestions);
43
- return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
43
+ return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
44
44
  }
45
45
  case 'CHOOSE_CORRECT_ADJECTIVE': {
46
46
  const data = transformChooseCorrectAdjective(apiQuestions);
@@ -242,6 +242,7 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
242
242
  });
243
243
  const isExample = item.isExample === true;
244
244
  const imageUrl = normalizeChooseAnswerGroupImageUrl(itemContent.imageUrl);
245
+ const audioUrl = asString(itemContent.audioUrl).trim();
245
246
  return {
246
247
  question: asString(itemContent.question),
247
248
  optionType: asString(itemContent.optionType, 'text') === 'image' ? 'image' : 'text',
@@ -255,13 +256,16 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
255
256
  points: isExample ? 0 : asNumber(item.points, 1),
256
257
  questionNumber: asNumber(item.questionNumber, index + 1),
257
258
  ...(imageUrl !== undefined ? { imageUrl } : {}),
259
+ ...(audioUrl ? { audioUrl } : {}),
258
260
  };
259
261
  });
262
+ const audioUrl = asString(asRecord(content.meta).audioUrl).trim();
260
263
  return {
261
264
  content: '',
262
265
  answer: {
263
266
  instruction: asString(asRecord(content.meta).instruction, 'Choose the correct answer.'),
264
267
  passage: asString(asRecord(content.meta).passage),
268
+ ...(audioUrl ? { audioUrl } : {}),
265
269
  items,
266
270
  explanation: asString(apiQuestion.explanation),
267
271
  points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
@@ -8,6 +8,7 @@ export interface ChooseTheCorrectAnswerGroupItemContent {
8
8
  options: ChooseTheCorrectAnswerGroupOption[];
9
9
  optionType?: 'text' | 'image';
10
10
  imageUrl?: string | string[];
11
+ audioUrl?: string;
11
12
  }
12
13
  export interface ChooseTheCorrectAnswerGroupItem {
13
14
  questionType?: 'CHOOSE_THE_CORRECT_ANSWER';
@@ -23,6 +24,7 @@ export interface ChooseTheCorrectAnswerGroupContent {
23
24
  meta?: {
24
25
  instruction?: string;
25
26
  passage?: string;
27
+ audioUrl?: string;
26
28
  };
27
29
  items: ChooseTheCorrectAnswerGroupItem[];
28
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",