import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getWpApi } from '../../api';
import { ApiError, getApiErrorToastTitle, getErrorSummary, preferToastForApiError } from '../../api/errors';
import { NavIcon } from '../../components/NavIcon';
import { TopRightToast, useTopRightToast } from '../../components/shared/TopRightToast';
import { appViewHref } from '../../lib/appUrl';
import { useAdminRouting } from '../../lib/adminRouting';
import { ApiErrorPanel } from '../../components/shared/ApiErrorPanel';
import { useSikshyaDialog } from '../../components/shared/SikshyaDialogContext';
import { ButtonPrimary } from '../../components/shared/buttons';
import { getSikshyaApi } from '../../api';
import { HorizontalEditorTabs } from '../../components/shared/HorizontalEditorTabs';
import { WPMediaPickerField } from '../../components/shared/WPMediaPickerField';
import { DateTimePickerField } from '../../components/shared/DateTimePickerField';
import { QuillField } from '../../components/shared/QuillField';
import { NumberWithUnitField } from '../../components/shared/NumberWithUnitField';
import type { SikshyaReactConfig } from '../../types';
import { GatedFeatureWorkspace } from '../../components/GatedFeatureWorkspace';
import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../../lib/licensing';
import { CertificateVisualBuilder } from './CertificateVisualBuilder';
import {
certificatePagePatternStoredValue,
DEFAULT_CERTIFICATE_PAGE_FINISH,
type CertificatePageFinish,
defaultCertificateLayout,
getPageAspectCss,
layoutToHtml,
layoutToStorage,
parseCertificatePageFinish,
parseLayoutFromMeta,
type CertLayoutFile,
} from './certificateLayout';
import {
contentFromPost,
excerptFromPost,
readMeta,
titleFromPost,
useWpContentPost,
wpRestExcerptPayload,
} from './useWpContentPost';
import {
PRO_ASSIGNMENT_DEFAULTS,
PRO_LESSON_DEFAULTS,
PRO_QUESTION_DEFAULTS,
PRO_QUIZ_DEFAULTS,
ProAssignmentFields,
ProGradebookAssignmentWeightFields,
ProGradebookQuizWeightFields,
ProLessonH5pBlock,
ProLessonLiveBlock,
ProLessonScormBlock,
ProQuestionFields,
ProQuizFields,
buildProAssignmentMeta,
buildProLessonMetaForKind,
buildProQuestionMeta,
QUIZ_ADVANCED_BANK_DRAW_HARD_MAX,
buildProQuizMeta,
readProAssignmentFromMeta,
readProLessonFromMeta,
readProQuestionFromMeta,
readProQuizFromMeta,
type ProAssignmentValues,
type ProLessonValues,
type ProQuestionValues,
type ProQuizValues,
} from './ProIntegrationFields';
import { useAddonEnabled } from '../../hooks/useAddons';
import {
AddQuestionAuthoringModal,
QUESTION_PICKER_TYPES,
type QuestionType,
} from '../../components/shared/AddQuestionAuthoringModal';
import { __ } from '../../lib/i18n';
const FIELD =
'mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus-visible:ring-brand-500/40 dark:border-slate-600 dark:bg-slate-800 dark:text-white';
const LABEL = 'block text-sm font-medium text-slate-800 dark:text-slate-200';
const HINT = 'mt-1 text-xs text-slate-500 dark:text-slate-400';
function FormSection(props: { title: string; description?: string; children: React.ReactNode }) {
const { title, description, children } = props;
return (
{title}
{description ? {description}
: null}
{children}
);
}
/** Resolve attachment URL for the WordPress media picker preview. */
function useAttachmentPreviewUrl(attachmentId: number): string {
const [url, setUrl] = useState('');
useEffect(() => {
if (!attachmentId || attachmentId <= 0) {
setUrl('');
return;
}
let cancelled = false;
void getWpApi()
.get<{ source_url?: string }>(`/media/${attachmentId}`)
.then((media) => {
if (!cancelled && media?.source_url) {
setUrl(media.source_url);
}
})
.catch(() => {
if (!cancelled) {
setUrl('');
}
});
return () => {
cancelled = true;
};
}, [attachmentId]);
return url;
}
function EditorFeaturedImageField(props: {
fieldId: string;
attachmentId: number;
onAttachmentIdChange: (id: number) => void;
description?: string;
}) {
const previewUrl = useAttachmentPreviewUrl(props.attachmentId);
return (
Featured image
{props.description ??
'Optional. Uses the WordPress featured image for this item when your theme or lists show thumbnails.'}
{}}
onAttachmentIdChange={props.onAttachmentIdChange}
className={FIELD}
placeholder={__('Opens the media library — upload a new image or choose an existing file.', 'sikshya')}
/>
);
}
function statusPillClass(status: string): string {
const s = String(status || '').toLowerCase();
if (s === 'publish' || s === 'published') {
return 'bg-emerald-50 text-emerald-800 ring-1 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-200 dark:ring-emerald-900/40';
}
if (s === 'pending') {
return 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-200 dark:ring-amber-900/40';
}
return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-700';
}
const DEFAULT_MCQ_OPTIONS = ['', '', '', ''];
function metaStringArray(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.map((x) => String(x));
}
function parseQuestionCorrectJson(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}
/** Full-width editor surface (matches dashboard main column usage). */
const EDITOR_SURFACE =
'overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900';
type EditorShellProps = {
loading: boolean;
saving: boolean;
error: unknown;
onRetry: () => void;
saveMsg: string | null;
/**
* When true, skip the default `space-y-8` wrapper so full-height workspaces
* (e.g. certificate builder) receive a bounded flex column from the parent.
*/
flush?: boolean;
children: React.ReactNode;
};
function EditorFormShell({ loading, saving: _saving, error, onRetry, saveMsg, flush, children }: EditorShellProps) {
const toast = useTopRightToast(3800);
const toastErrorSigRef = useRef(null);
useEffect(() => {
if (!saveMsg) return;
toast.success(__('Saved', 'sikshya'), saveMsg);
}, [saveMsg, toast]);
useEffect(() => {
if (!error) {
toastErrorSigRef.current = null;
return;
}
if (!preferToastForApiError(error)) {
return;
}
const summary = getErrorSummary(error);
const sig =
error instanceof ApiError
? `${error.method}:${error.status}:${error.url}:${summary}`
: `e:${summary}`;
if (toastErrorSigRef.current === sig) {
return;
}
toastErrorSigRef.current = sig;
toast.show({
open: true,
kind: 'error',
title: getApiErrorToastTitle(error),
message: summary,
ttlMs: 10000,
});
}, [error, toast]);
const showErrorPanel = Boolean(error) && !preferToastForApiError(error);
return (
<>
{showErrorPanel ? (
) : null}
{loading ? (
Loading…
) : flush ? (
{children}
) : (
{children}
)}
>
);
}
export type ContentEditorProps = {
config: SikshyaReactConfig;
postType: string;
postId: number;
backHref: string;
entityLabel: string;
onSavedNewId?: (newId: number) => void;
/** Hide “Back to list” for embedded panels (e.g. course builder curriculum). */
embedded?: boolean;
/** When set, chapter editor locks parent course to this ID. */
forcedCourseId?: number;
/**
* When embedded, allows the parent (Course Builder) to flush pending edits
* before saving or publishing the course. Pass null on editor unmount.
*/
exposeSave?: (saveFn: (() => Promise) | null) => void;
};
function useMoveToTrash(
editor: ReturnType,
backHref: string,
entityLabel: string
) {
const { confirm } = useSikshyaDialog();
const { navigateHref } = useAdminRouting();
return useCallback(() => {
void (async () => {
if (editor.isNew) {
return;
}
const ok = await confirm({
title: __('Move to trash?', 'sikshya'),
message: `Move this ${entityLabel.toLowerCase()} to the trash? You can restore it later from the Trash tab.`,
variant: 'danger',
confirmLabel: __('Move to trash', 'sikshya'),
});
if (!ok) {
return;
}
try {
await editor.remove();
navigateHref(backHref);
} catch {
/* handled by editor error state */
}
})();
}, [editor, backHref, entityLabel, confirm, navigateHref]);
}
export function LessonEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded } = props;
const editor = useWpContentPost('sik_lesson', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [featured, setFeatured] = useState(0);
const [durationValue, setDurationValue] = useState('');
const [durationUnit, setDurationUnit] = useState<'min' | 'hr'>('min');
const [lessonType, setLessonType] = useState('text');
const [videoUrl, setVideoUrl] = useState('');
// Optional transcript shown beside the player on the Learn page — keep
// both an external file URL (downloadable) and a paste-in text (inline
// disclosure) for flexibility. Either, both, or neither may be set.
const [transcriptUrl, setTranscriptUrl] = useState('');
const [transcriptText, setTranscriptText] = useState('');
const [isFreePreview, setIsFreePreview] = useState(false);
const [saveMsg, setSaveMsg] = useState(null);
const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content');
const [proValues, setProValues] = useState(PRO_LESSON_DEFAULTS);
const liveAddon = useAddonEnabled('live_classes');
const scormAddon = useAddonEnabled('scorm_h5p_pro');
const liveReady = Boolean(liveAddon.enabled && liveAddon.licenseOk);
const scormReady = Boolean(scormAddon.enabled && scormAddon.licenseOk);
const liveOffered = Boolean(liveAddon.loading) || liveAddon.licenseOk === true;
// While the Addons catalog is still loading, licenseOk is null — don't hide Pro lesson kinds (users thought SCORM/H5P were "missing").
const scormOffered = Boolean(scormAddon.loading) || scormAddon.licenseOk === true;
useEffect(() => {
if (editor.isNew) {
setTitle('');
setExcerpt('');
setContent('');
setStatus('draft');
setFeatured(0);
setDurationValue('');
setDurationUnit('min');
setLessonType('text');
setVideoUrl('');
setTranscriptUrl('');
setTranscriptText('');
setProValues(PRO_LESSON_DEFAULTS);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setExcerpt(excerptFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
const m = p.meta as Record | undefined;
const rawDur = String(readMeta(m, '_sikshya_lesson_duration') ?? '').trim();
if (!rawDur) {
setDurationValue('');
setDurationUnit('min');
} else {
const lower = rawDur.toLowerCase();
const unit: 'min' | 'hr' = lower.includes('h') ? 'hr' : 'min';
const num = lower
.replace(/hours?|hrs?|hr|minutes?|mins?|min/g, '')
.replace(/[^\d.]/g, '')
.trim();
setDurationValue(num || rawDur);
setDurationUnit(unit);
}
setLessonType(String(readMeta(m, '_sikshya_lesson_type') ?? 'text') || 'text');
setVideoUrl(String(readMeta(m, '_sikshya_lesson_video_url') ?? ''));
setTranscriptUrl(String(readMeta(m, '_sikshya_lesson_transcript_url') ?? ''));
setTranscriptText(String(readMeta(m, '_sikshya_lesson_transcript_text') ?? ''));
setIsFreePreview(Boolean(readMeta(m, '_sikshya_is_free') === true || String(readMeta(m, '_sikshya_is_free') ?? '') === '1'));
setProValues(readProLessonFromMeta(m));
}, [editor.post, editor.isNew]);
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
const kind = (lessonType.trim() || 'text');
const durNum = parseFloat(durationValue);
const duration =
durationValue.trim() && Number.isFinite(durNum) && durNum > 0
? `${durNum}${Number.isInteger(durNum) ? '' : ''} ${durationUnit === 'hr' ? __('hr', 'sikshya') : __('min', 'sikshya')}`
: '';
const body: Record = {
title,
content,
status,
excerpt: wpRestExcerptPayload(excerpt),
featured_media: featured > 0 ? featured : 0,
meta: {
_sikshya_lesson_duration: duration,
_sikshya_lesson_type: kind,
_sikshya_lesson_video_url: kind === 'video' ? videoUrl.trim() : '',
_sikshya_lesson_transcript_url: transcriptUrl.trim(),
_sikshya_lesson_transcript_text: transcriptText,
_sikshya_is_free: isFreePreview ? '1' : '0',
...buildProLessonMetaForKind(kind, proValues),
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Lesson saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* error in hook */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [
embedded,
props.exposeSave,
title,
content,
excerpt,
status,
featured,
durationValue,
durationUnit,
lessonType,
videoUrl,
transcriptUrl,
transcriptText,
isFreePreview,
proValues,
]);
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings')}
/>
{title?.trim() ? title : 'Lesson'}
Lessons are the ordered curriculum steps. SCORM packages and H5P content attach here as one playable activity per lesson (course settings only tune defaults across those lessons).
{status || 'draft'}
{durationValue?.trim() ? (
{`${durationValue.trim()} ${durationUnit === 'hr' ? __('hr', 'sikshya') : __('min', 'sikshya')}`}
) : null}
{lessonType === 'video'
? 'Video'
: lessonType === 'live'
? 'Live class'
: lessonType === 'scorm'
? 'SCORM'
: lessonType === 'h5p'
? 'H5P'
: 'Text'}
{editorTab === 'content' ? (
Lesson title
setTitle(e.target.value)}
placeholder={__('e.g. Installing WordPress', 'sikshya')}
/>
Short summary
A short blurb for lesson lists; optional but helps learners scan the outline.
Lesson type
Pick how learners experience this step. Each kind shows its own field set below.
setLessonType(e.target.value)}
>
{__('Text lesson', 'sikshya')}
{__('Video lesson', 'sikshya')}
{liveOffered ? (
{liveReady
? __('Live class (Pro)', 'sikshya')
: __('Live class (Pro · addon off)', 'sikshya')}
) : (
{__('Live class (requires Sikshya Pro)', 'sikshya')}
)}
{scormOffered ? (
{scormReady
? __('SCORM package (Pro)', 'sikshya')
: __('SCORM package (Pro · addon off)', 'sikshya')}
) : (
{__('SCORM package (requires Sikshya Pro)', 'sikshya')}
)}
{scormOffered ? (
{scormReady
? __('H5P interactive (Pro)', 'sikshya')
: __('H5P interactive (Pro · addon off)', 'sikshya')}
) : (
{__('H5P interactive (requires Sikshya Pro)', 'sikshya')}
)}
{lessonType === 'video' ? (
) : null}
{(lessonType === 'video' || lessonType === 'audio') ? (
) : null}
{lessonType === 'live' ? (
) : null}
{lessonType === 'scorm' ? (
) : null}
{lessonType === 'h5p' ? (
) : null}
setContent(html)}
placeholder={lessonType === 'video' ? __('Optional notes under the video…', 'sikshya') : __('Your lesson content…', 'sikshya')}
disabled={editor.saving}
minHeightPx={360}
/>
) : (
Status
{/* Keep row height aligned with Duration (which has a hint line). */}
placeholder
setStatus(e.target.value)}
>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
{__('Private', 'sikshya')}
{__('Pending review', 'sikshya')}
setDurationUnit(u === 'hr' ? 'hr' : 'min')}
units={[
{ value: 'min', label: 'Minutes' },
{ value: 'hr', label: 'Hours' },
]}
fieldClassName={FIELD}
labelClassName={LABEL}
hintClassName={HINT}
placeholder={durationUnit === 'hr' ? __('e.g. 1.5', 'sikshya') : __('e.g. 12', 'sikshya')}
/>
setIsFreePreview(e.target.checked)}
disabled={editor.saving}
/>
{__('Allow free preview', 'sikshya')}
Tip: mark one or two lessons as preview so learners understand the teaching style before purchasing.
)}
{embedded ? (
void onSave()} />
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
/** Course builder side panel: save CPT only; no trash / no full-width action bar. */
function EmbeddedSaveBar(props: { saving: boolean; entityLabel: string; canSave?: boolean; onSave: () => void }) {
const { saving, entityLabel, canSave = true, onSave } = props;
return (
Saves this {entityLabel.toLowerCase()} only. Save draft / Publish in the course toolbar also saves the open item here first.
{saving ? 'Saving…' : `Save ${entityLabel.toLowerCase()}`}
{!canSave ? (
{__('Add the required fields (usually title) to enable saving.', 'sikshya')}
) : null}
);
}
function EditorActions(props: {
backHref: string;
entityLabel: string;
saving: boolean;
isNew: boolean;
canSave?: boolean;
onSave: () => void;
onTrash: () => void;
}) {
const { backHref, saving, isNew, canSave = true, onSave, onTrash } = props;
return (
← Back to list
{saving ? __('Saving…', 'sikshya') : __('Save', 'sikshya')}
{!isNew ? (
Move to trash
) : null}
{!canSave ? (
{__('Add the required fields (usually title) to enable saving.', 'sikshya')}
) : null}
);
}
export function QuizEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded, config } = props;
const editor = useWpContentPost('sik_quiz', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const advQuiz = useAddonEnabled('quiz_advanced');
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [timeLimit, setTimeLimit] = useState(0);
const [passing, setPassing] = useState(70);
const [attempts, setAttempts] = useState(3);
const [quizQuestionIds, setQuizQuestionIds] = useState([]);
const [questionSearch, setQuestionSearch] = useState('');
const [questionRows, setQuestionRows] = useState<{ id: number; title: string }[]>([]);
const [questionLoading, setQuestionLoading] = useState(false);
const [questionError, setQuestionError] = useState(null);
const [questionRefresh, setQuestionRefresh] = useState(0);
const [addQuestionOpen, setAddQuestionOpen] = useState(false);
const [addQuestionEditId, setAddQuestionEditId] = useState(null);
const [saveMsg, setSaveMsg] = useState(null);
const [featured, setFeatured] = useState(0);
const [editorTab, setEditorTab] = useState<'content' | 'settings' | 'questions'>('content');
const [proQuizValues, setProQuizValues] = useState(PRO_QUIZ_DEFAULTS);
const [quizAdvMaxDraw] = useState(QUIZ_ADVANCED_BANK_DRAW_HARD_MAX);
const QUIZ_Q_DND_MIME = 'application/x-sikshya-quiz-questions-v1';
const reorderIds = (ids: number[], fromId: number, beforeId: number | null) => {
const fromIdx = ids.indexOf(fromId);
if (fromIdx < 0) {
return ids;
}
if (beforeId === fromId) {
return ids;
}
const next = [...ids];
next.splice(fromIdx, 1);
let insertAt = next.length;
if (beforeId != null) {
const i = next.indexOf(beforeId);
if (i >= 0) {
insertAt = i;
}
}
next.splice(insertAt, 0, fromId);
return next;
};
useEffect(() => {
if (editor.isNew) {
setTitle('');
setContent('');
setStatus('draft');
setTimeLimit(0);
setPassing(70);
setAttempts(3);
setQuizQuestionIds([]);
setFeatured(0);
setProQuizValues(PRO_QUIZ_DEFAULTS);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
const m = p.meta as Record | undefined;
setTimeLimit(Number(readMeta(m, '_sikshya_quiz_time_limit') ?? 0));
setPassing(Number(readMeta(m, '_sikshya_quiz_passing_score') ?? 70));
setAttempts(Number(readMeta(m, '_sikshya_quiz_attempts_allowed') ?? 3));
const qids = readMeta(m, '_sikshya_quiz_questions');
if (Array.isArray(qids)) {
setQuizQuestionIds(qids.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0));
} else {
setQuizQuestionIds([]);
}
setProQuizValues(readProQuizFromMeta(m));
}, [editor.post, editor.isNew]);
useEffect(() => {
if (editorTab !== 'questions') {
return;
}
let cancelled = false;
setQuestionLoading(true);
setQuestionError(null);
const q = new URLSearchParams({
per_page: '50',
status: 'any',
context: 'edit',
});
if (questionSearch.trim()) {
q.set('search', questionSearch.trim());
}
void getWpApi()
.get<{ id: number; title?: { raw?: string; rendered?: string } }[]>(`/sik_question?${q.toString()}`)
.then((rows) => {
if (cancelled || !Array.isArray(rows)) {
return;
}
setQuestionRows(
rows.map((r) => ({
id: Number(r.id) || 0,
title: r.title?.raw || r.title?.rendered?.replace(/<[^>]+>/g, '') || `Question #${r.id}`,
}))
);
})
.catch((e) => {
if (!cancelled) {
setQuestionError(e);
setQuestionRows([]);
}
})
.finally(() => {
if (!cancelled) {
setQuestionLoading(false);
}
});
return () => {
cancelled = true;
};
}, [editorTab, questionSearch, questionRefresh]);
const openAddQuestion = () => {
setAddQuestionEditId(null);
setAddQuestionOpen(true);
};
const openEditQuestionInModal = (questionId: number) => {
if (!questionId) {
return;
}
setAddQuestionEditId(questionId);
setAddQuestionOpen(true);
};
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
const body: Record = {
title,
content,
status,
featured_media: featured > 0 ? featured : 0,
meta: {
_sikshya_quiz_time_limit: Math.max(0, timeLimit),
_sikshya_quiz_passing_score: Math.min(100, Math.max(0, passing)),
_sikshya_quiz_attempts_allowed: Math.max(1, attempts),
_sikshya_quiz_questions: quizQuestionIds,
...buildProQuizMeta(
proQuizValues,
advQuiz.enabled && advQuiz.licenseOk ? quizAdvMaxDraw : QUIZ_ADVANCED_BANK_DRAW_HARD_MAX
),
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Quiz saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* hook sets error */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [
embedded,
props.exposeSave,
title,
content,
status,
featured,
timeLimit,
passing,
attempts,
quizQuestionIds,
proQuizValues,
quizAdvMaxDraw,
]);
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings' | 'questions')}
/>
{title?.trim() ? title : 'Quiz'}
{embedded ? (
Use {__('Content', 'sikshya')} for the name, instructions,
and optional cover image; use {__('Settings', 'sikshya')} for
timing and scoring. Questions live in the{' '}
Questions
{' '}
library.
) : (
Use {__('Content', 'sikshya')} for the quiz name and what
students read before starting; use {__('Settings', 'sikshya')} for
timer, pass mark, and attempts. Attach questions on the {__('Questions', 'sikshya')} tab.
)}
{status || 'draft'}
{Number(timeLimit) > 0 ? (
{Number(timeLimit)} min
) : null}
{editorTab === 'content' ? (
setContent(html)}
placeholder={__('What students see before starting the quiz…', 'sikshya')}
disabled={editor.saving}
minHeightPx={220}
/>
) : editorTab === 'settings' ? (
Status
setStatus(e.target.value)}>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
{__('Private', 'sikshya')}
setProQuizValues((v) => ({ ...v, gradeWeight: w }))}
/>
) : (
{questionError ? (
setQuestionSearch((s) => s)} />
) : null}
{questionLoading ? (
{__('Loading questions…', 'sikshya')}
) : (
{__('Library', 'sikshya')}
{questionRows.map((q) => {
if (!q.id) return null;
const added = quizQuestionIds.includes(q.id);
return (
{q.title || `Question #${q.id}`}
setQuizQuestionIds((prev) => (prev.includes(q.id) ? prev : [...prev, q.id]))}
>
{added ? __('Added', 'sikshya') : __('Add', 'sikshya')}
);
})}
{!questionRows.length ? (
No questions found.
) : null}
{__('Selected in this quiz', 'sikshya')}
{quizQuestionIds.map((qid) => {
const row = questionRows.find((r) => r.id === qid);
return (
{
if (![...e.dataTransfer.types].includes(QUIZ_Q_DND_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const raw = e.dataTransfer.getData(QUIZ_Q_DND_MIME);
const fromId = Number(raw);
if (!fromId) return;
setQuizQuestionIds((prev) => reorderIds(prev, fromId, qid));
}}
>
{
e.dataTransfer.setData(QUIZ_Q_DND_MIME, String(qid));
e.dataTransfer.effectAllowed = 'move';
}}
title={__('Drag to reorder', 'sikshya')}
aria-label={__('Drag to reorder', 'sikshya')}
>
{row?.title || `Question #${qid}`}
openEditQuestionInModal(qid)}
>
Edit
setQuizQuestionIds((prev) => prev.filter((x) => x !== qid))}
>
Remove
);
})}
{!quizQuestionIds.length ? (
No questions added yet.
) : null}
Changes are saved when you click “Save”.
)}
)}
{
setAddQuestionOpen(false);
setAddQuestionEditId(null);
}}
editQuestionId={addQuestionEditId}
onUpdated={() => setQuestionRefresh((n) => n + 1)}
onCreated={(id) => {
if (!id) return;
setQuizQuestionIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setQuestionRefresh((n) => n + 1);
}}
onPickExisting={
addQuestionEditId
? undefined
: (id) => {
if (!id) return;
setQuizQuestionIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setAddQuestionOpen(false);
}
}
pickExistingLabel="Add to quiz"
/>
{embedded ? (
void onSave()} />
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
export function QuestionEditor(props: ContentEditorProps) {
const { config, postId, backHref, entityLabel, onSavedNewId, embedded } = props;
const editor = useWpContentPost('sik_question', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [qType, setQType] = useState('');
const [points, setPoints] = useState(1);
const [options, setOptions] = useState(() => [...DEFAULT_MCQ_OPTIONS]);
const [correctAnswer, setCorrectAnswer] = useState('');
const [multiCorrect, setMultiCorrect] = useState([]);
const [matchLeft, setMatchLeft] = useState(['', '']);
const [matchRight, setMatchRight] = useState(['', '']);
const [matchMap, setMatchMap] = useState([0, 0]);
const [orderItems, setOrderItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const [orderPerm, setOrderPerm] = useState([0, 1, 2]);
const [featured, setFeatured] = useState(0);
const [saveMsg, setSaveMsg] = useState(null);
const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content');
const [proQuestionValues, setProQuestionValues] = useState(PRO_QUESTION_DEFAULTS);
const [typeMenuPos, setTypeMenuPos] = useState<{ top: number; left: number } | null>(null);
const advQuiz = useAddonEnabled('quiz_advanced');
const advFeatureOk = isFeatureEnabled(config, 'quiz_advanced');
const canUseAdvancedTypes = Boolean(advFeatureOk && advQuiz.enabled && advQuiz.licenseOk);
const isLockedType = useCallback(
(t: string) => {
const key = String(t || '').trim();
const needs = QUESTION_PICKER_TYPES.find((x) => x.type === key)?.requiresAdvancedQuiz;
return Boolean(needs && !canUseAdvancedTypes);
},
[canUseAdvancedTypes]
);
useEffect(() => {
if (editor.isNew) {
setTitle('');
setContent('');
setStatus('draft');
setFeatured(0);
setQType('');
setPoints(1);
setOptions([...DEFAULT_MCQ_OPTIONS]);
setCorrectAnswer('');
setMultiCorrect([]);
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
setOrderItems(['Item 1', 'Item 2', 'Item 3']);
setOrderPerm([0, 1, 2]);
setProQuestionValues(PRO_QUESTION_DEFAULTS);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
const m = p.meta as Record | undefined;
const t = String(readMeta(m, '_sikshya_question_type') ?? '');
setQType(t);
setPoints(Number(readMeta(m, '_sikshya_question_points') ?? 1));
setProQuestionValues(readProQuestionFromMeta(m));
const loadedOpts = metaStringArray(readMeta(m, '_sikshya_question_options'));
const rawCorrect = String(readMeta(m, '_sikshya_question_correct_answer') ?? '');
if (t === 'matching') {
setOptions([]);
const parsed = parseQuestionCorrectJson(rawCorrect) as { matching?: { left?: string[]; right?: string[]; map?: number[] } } | null;
const mm = parsed && typeof parsed === 'object' && parsed !== null ? parsed.matching : undefined;
if (mm && Array.isArray(mm.left) && Array.isArray(mm.right)) {
setMatchLeft(mm.left.map(String));
setMatchRight(mm.right.map(String));
const n = mm.left.length;
const map =
Array.isArray(mm.map) && mm.map.length === n ? mm.map.map((x) => Number(x)) : mm.left.map((_, i) => i);
setMatchMap(map);
} else {
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
}
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
if (t === 'ordering') {
const items = loadedOpts.length >= 2 ? loadedOpts : ['Item 1', 'Item 2', 'Item 3'];
setOrderItems(items);
const permRaw = parseQuestionCorrectJson(rawCorrect);
if (Array.isArray(permRaw) && permRaw.length === items.length && permRaw.every((x) => typeof x === 'number')) {
setOrderPerm(permRaw as number[]);
} else {
setOrderPerm(items.map((_, i) => i));
}
setOptions(items);
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
if (t === 'multiple_response') {
setOptions(loadedOpts.length >= 2 ? loadedOpts : [...DEFAULT_MCQ_OPTIONS]);
const parsed = parseQuestionCorrectJson(rawCorrect);
setMultiCorrect(
Array.isArray(parsed) ? parsed.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n >= 0) : [],
);
setCorrectAnswer('');
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
return;
}
if (t === 'true_false') {
setOptions(['True', 'False']);
setCorrectAnswer(rawCorrect === 'false' ? 'false' : 'true');
setMultiCorrect([]);
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
return;
}
setOptions(loadedOpts.length >= 2 ? loadedOpts : [...DEFAULT_MCQ_OPTIONS]);
setCorrectAnswer(rawCorrect);
setMultiCorrect([]);
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
setOrderItems(['Item 1', 'Item 2', 'Item 3']);
setOrderPerm([0, 1, 2]);
}, [editor.post, editor.isNew]);
const onTypeChange = (v: string) => {
if (isLockedType(v)) {
return;
}
setQType(v);
if (v === 'true_false') {
setCorrectAnswer('true');
setOptions(['True', 'False']);
setMultiCorrect([]);
return;
}
if (v === 'multiple_choice') {
setOptions([...DEFAULT_MCQ_OPTIONS]);
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
if (v === 'multiple_response') {
setOptions([...DEFAULT_MCQ_OPTIONS]);
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
if (v === 'matching') {
setMatchLeft(['', '']);
setMatchRight(['', '']);
setMatchMap([0, 0]);
setOptions([]);
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
if (v === 'ordering') {
setOrderItems(['Item 1', 'Item 2', 'Item 3']);
setOrderPerm([0, 1, 2]);
setOptions(['Item 1', 'Item 2', 'Item 3']);
setCorrectAnswer('');
setMultiCorrect([]);
return;
}
setOptions([...DEFAULT_MCQ_OPTIONS]);
setCorrectAnswer('');
setMultiCorrect([]);
};
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
const trimmedOptions = options.map((o) => o.trim()).filter(Boolean);
let optionsPayload: string[] = [];
let correctPayload = '';
if (qType === 'multiple_choice') {
optionsPayload = trimmedOptions;
correctPayload = correctAnswer;
} else if (qType === 'multiple_response') {
optionsPayload = trimmedOptions;
correctPayload = JSON.stringify([...multiCorrect].sort((a, b) => a - b));
} else if (qType === 'true_false') {
optionsPayload = [];
correctPayload = correctAnswer === 'false' ? 'false' : 'true';
} else if (qType === 'short_answer' || qType === 'fill_blank') {
optionsPayload = [];
correctPayload = correctAnswer.trim();
} else if (qType === 'essay') {
optionsPayload = [];
correctPayload = '';
} else if (qType === 'matching') {
optionsPayload = [];
correctPayload = JSON.stringify({
matching: {
left: matchLeft.map((x) => x.trim()),
right: matchRight.map((x) => x.trim()),
map: matchMap.map((x) => Number(x)),
},
});
} else if (qType === 'ordering') {
const items = orderItems.map((x) => x.trim()).filter(Boolean);
const useItems = items.length >= 2 ? items : ['Item 1', 'Item 2'];
optionsPayload = useItems;
const perm = orderPerm.length === useItems.length ? orderPerm : useItems.map((_, i) => i);
correctPayload = JSON.stringify(perm);
}
const body: Record = {
title,
content,
status,
featured_media: featured > 0 ? featured : 0,
meta: {
_sikshya_question_type: qType,
_sikshya_question_points: Math.max(0, points),
_sikshya_question_options: optionsPayload,
_sikshya_question_correct_answer: correctPayload,
...buildProQuestionMeta(proQuestionValues),
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Question saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* hook */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [
embedded,
props.exposeSave,
title,
content,
status,
featured,
qType,
points,
options,
correctAnswer,
multiCorrect,
matchLeft,
matchRight,
matchMap,
orderItems,
orderPerm,
proQuestionValues,
]);
const moveOrderSlot = (pos: number, dir: -1 | 1) => {
setOrderPerm((prev) => {
const j = pos + dir;
if (j < 0 || j >= prev.length) {
return prev;
}
const next = [...prev];
const tmp = next[pos];
next[pos] = next[j];
next[j] = tmp;
return next;
});
};
const openTypeMenu = (el: HTMLElement | null) => {
if (!el) {
setTypeMenuPos(null);
return;
}
const rect = el.getBoundingClientRect();
setTypeMenuPos({ top: rect.bottom + 8, left: Math.min(rect.left, window.innerWidth - 320) });
};
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings')}
/>
{title?.trim() ? title : 'Question'}
Content: type, question, answers, optional illustration. Settings: points and publish status. Reuse questions from the Questions library in any quiz.
{status || 'draft'}
{Number(points) > 0 ? (
{Number(points)} pts
) : null}
{qType?.trim() ? (
{qType.replace(/_/g, ' ')}
) : null}
{editorTab === 'content' ? (
Question type
Each type shows different fields — multiple choice, matching, essay, and so on.
openTypeMenu(e.currentTarget)}
>
{qType
? QUESTION_PICKER_TYPES.find((t) => t.type === (qType as QuestionType))?.label || qType.replace(/_/g, ' ')
: 'Select question type'}
{qType
? QUESTION_PICKER_TYPES.find((t) => t.type === (qType as QuestionType))?.hint || 'Answers depend on the chosen type.'
: 'Choose the format first — it will unlock the right answer fields.'}
Question text
{__('What the learner sees (plain text or short HTML).', 'sikshya')}
{!qType ? (
{__('Choose a question type to continue', 'sikshya')}
Once you pick a type, Sikshya will show the right answer fields (options, matching pairs, ordering, etc.).
{QUESTION_PICKER_TYPES.slice(0, 6).map((t) => {
const locked = isLockedType(t.type);
return (
onTypeChange(t.type)}
>
{t.label}
{locked ? (
Pro
) : null}
);
})}
) : null}
{qType === 'multiple_choice' ? (
{__('Answer choices', 'sikshya')}
{__('At least two options; mark the correct one.', 'sikshya')}
setOptions((prev) => [...prev, ''])}
>
+ Add option
) : null}
{qType === 'multiple_response' ? (
{__('Answer choices', 'sikshya')}
{__('Check every option that should be marked correct.', 'sikshya')}
setOptions((prev) => [...prev, ''])}
>
+ Add option
) : null}
{qType === 'true_false' ? (
Correct answer
{__('Learners choose between true and false.', 'sikshya')}
setCorrectAnswer(e.target.value)}
>
{__('True', 'sikshya')}
{__('False', 'sikshya')}
) : null}
{qType === 'short_answer' || qType === 'fill_blank' ? (
) : null}
{qType === 'ordering' ? (
{__('Steps / items', 'sikshya')}
{__('Edit labels, then arrange the correct order (top = first).', 'sikshya')}
{
setOrderItems((prev) => [...prev, '']);
setOrderPerm((prev) => [...prev, prev.length]);
}}
>
+ Add item
{__('Correct order', 'sikshya')}
{orderPerm.map((itemIdx, pos) => (
{orderItems[itemIdx] ?? `Item ${itemIdx}`}
moveOrderSlot(pos, -1)}
disabled={pos === 0}
>
Up
moveOrderSlot(pos, 1)}
disabled={pos >= orderPerm.length - 1}
>
Down
))}
) : null}
{qType === 'matching' ? (
{__('Matching pairs', 'sikshya')}
{__('For each prompt on the left, choose the matching answer column index.', 'sikshya')}
{__('Answer column', 'sikshya')}
{
setMatchLeft((prev) => [...prev, '']);
setMatchMap((prev) => [...prev, 0]);
}}
>
+ Add prompt
setMatchRight((prev) => [...prev, ''])}
>
+ Add answer
) : null}
{qType === 'essay' ? (
Essays are graded manually. Use the explanation field below for model answers or staff notes.
) : null}
setContent(html)}
placeholder={__('Optional explanation for learners or grading notes…', 'sikshya')}
disabled={editor.saving}
minHeightPx={220}
/>
) : (
Status
{__('Draft stays hidden from learners until published.', 'sikshya')}
setStatus(e.target.value)}>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
)}
{typeMenuPos ? (
setTypeMenuPos(null)}
aria-hidden
/>
) : null}
{typeMenuPos ? (
Select question type
{QUESTION_PICKER_TYPES.map((t) => {
const active = qType === t.type;
const locked = isLockedType(t.type);
const lockTitle = locked ? 'Enable Advanced Quiz add-on to use this type.' : undefined;
return (
{
onTypeChange(t.type);
setTypeMenuPos(null);
}}
>
{t.label}
{locked ? (
Pro
) : null}
{t.hint}
);
})}
) : null}
{embedded ? (
void onSave()}
/>
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
export function AssignmentEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded } = props;
const editor = useWpContentPost('sik_assignment', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [due, setDue] = useState('');
const [apoints, setApoints] = useState(10);
const [atype, setAtype] = useState('');
const [featured, setFeatured] = useState(0);
const [saveMsg, setSaveMsg] = useState(null);
const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content');
const [proAsgValues, setProAsgValues] = useState(PRO_ASSIGNMENT_DEFAULTS);
useEffect(() => {
if (editor.isNew) {
setTitle('');
setContent('');
setStatus('draft');
setDue('');
setApoints(10);
setAtype('');
setFeatured(0);
setProAsgValues(PRO_ASSIGNMENT_DEFAULTS);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
const m = p.meta as Record | undefined;
setDue(String(readMeta(m, '_sikshya_assignment_due_date') ?? ''));
setApoints(Number(readMeta(m, '_sikshya_assignment_points') ?? 10));
setAtype(String(readMeta(m, '_sikshya_assignment_type') ?? ''));
setProAsgValues(readProAssignmentFromMeta(m));
}, [editor.post, editor.isNew]);
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
const body: Record = {
title,
content,
status,
featured_media: featured > 0 ? featured : 0,
meta: {
_sikshya_assignment_due_date: due,
_sikshya_assignment_points: Math.max(0, apoints),
_sikshya_assignment_type: atype,
...buildProAssignmentMeta(proAsgValues),
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Assignment saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* hook */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [embedded, props.exposeSave, title, content, status, featured, due, apoints, atype, proAsgValues]);
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings')}
/>
{title?.trim() ? title : 'Assignment'}
Content holds the title, instructions, and optional cover image; Settings holds due date, points, submission type, and status.
{status || 'draft'}
{Math.max(0, Number.isFinite(apoints) ? apoints : 0)} pts
{due?.trim() ? (
Due {due}
) : null}
{atype?.trim() ? (
{atype}
) : null}
{editorTab === 'content' ? (
) : (
{/* Same hint row height in each column so inputs line up */}
Due (datetime)
{__('Optional. Uses your site timezone.', 'sikshya')}
Submission type
setAtype(e.target.value)}>
{__('Choose how learners submit…', 'sikshya')}
{__('Essay', 'sikshya')}
{__('File upload', 'sikshya')}
{__('URL', 'sikshya')}
Status
setStatus(e.target.value)}>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
setProAsgValues((v) => ({ ...v, gradeWeight: w }))}
/>
)}
{embedded ? (
void onSave()} />
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
type CourseOpt = { id: number; title: string };
export function ChapterEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded, forcedCourseId } = props;
const editor = useWpContentPost('sik_chapter', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [order, setOrder] = useState(0);
const [courseId, setCourseId] = useState(0);
const [courses, setCourses] = useState([]);
const [featured, setFeatured] = useState(0);
const [saveMsg, setSaveMsg] = useState(null);
const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content');
useEffect(() => {
let cancelled = false;
void getWpApi()
.get<{ id: number; title: { rendered: string } }[]>('/sik_course?per_page=100&status=any&orderby=title&order=asc')
.then((rows) => {
if (cancelled || !Array.isArray(rows)) {
return;
}
setCourses(
rows.map((r) => ({
id: r.id,
title: r.title?.rendered ? r.title.rendered.replace(/<[^>]+>/g, '') : `Course #${r.id}`,
}))
);
})
.catch(() => {
if (!cancelled) {
setCourses([]);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (forcedCourseId && forcedCourseId > 0) {
setCourseId(forcedCourseId);
}
}, [forcedCourseId]);
useEffect(() => {
if (editor.isNew) {
setTitle('');
setContent('');
setStatus('draft');
setOrder(0);
setFeatured(0);
setCourseId(forcedCourseId && forcedCourseId > 0 ? forcedCourseId : 0);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
const m = p.meta as Record | undefined;
setOrder(Number(readMeta(m, '_sikshya_chapter_order') ?? 0));
const fromMeta = Number(readMeta(m, '_sikshya_chapter_course_id') ?? 0);
setCourseId(fromMeta > 0 ? fromMeta : forcedCourseId && forcedCourseId > 0 ? forcedCourseId : 0);
}, [editor.post, editor.isNew, forcedCourseId]);
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
const effectiveCourse = forcedCourseId && forcedCourseId > 0 ? forcedCourseId : courseId;
const body: Record = {
title,
content,
status,
featured_media: featured > 0 ? featured : 0,
meta: {
_sikshya_chapter_order: Math.max(0, order),
_sikshya_chapter_course_id: effectiveCourse > 0 ? effectiveCourse : 0,
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Chapter saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* hook */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [embedded, props.exposeSave, title, content, status, featured, order, courseId, forcedCourseId]);
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings')}
/>
{title?.trim() ? title : 'Chapter'}
Content: chapter title, intro text, and optional cover image. Settings: which course it belongs to, sort order, and publish status.
{status || 'draft'}
{order > 0 ? (
Order {order}
) : null}
{editorTab === 'content' ? (
) : (
Parent course
{forcedCourseId && forcedCourseId > 0 ? (
{courses.find((c) => c.id === forcedCourseId)?.title || `Course #${forcedCourseId}`}
) : (
setCourseId(Number(e.target.value))}
>
{__('Choose a course…', 'sikshya')}
{courses.map((c) => (
{c.title}
))}
)}
Sort order
setOrder(Number(e.target.value))}
/>
Status
setStatus(e.target.value)}>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
)}
{embedded ? (
void onSave()} />
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
/** Visual certificate builder (drag-and-drop canvas) + generated HTML in post content. */
export function CertificateEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded, config } = props;
const editor = useWpContentPost('sikshya_certificate', postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const toast = useTopRightToast(2600);
const [title, setTitle] = useState('');
const [status, setStatus] = useState('draft');
const [featuredId, setFeaturedId] = useState(0);
const [featuredPreview, setFeaturedPreview] = useState('');
const [orientation, setOrientation] = useState<'landscape' | 'portrait'>('landscape');
const [pageSize, setPageSize] = useState<'letter' | 'a4' | 'a5'>('a4');
const [pageFinish, setPageFinish] = useState(DEFAULT_CERTIFICATE_PAGE_FINISH);
const [layout, setLayout] = useState(() => defaultCertificateLayout());
const layoutRef = useRef(layout);
// Certificate Builder is a Pro addon feature; gate the workspace when it's off.
const featureOk = isFeatureEnabled(config, 'certificates_advanced');
const addon = useAddonEnabled('certificates_advanced');
const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading);
// Saving can be triggered immediately after a drag/resize gesture; keep a ref so we always use the latest layout.
useEffect(() => {
layoutRef.current = layout;
}, [layout]);
useEffect(() => {
if (editor.isNew) {
setTitle('');
setStatus('draft');
setFeaturedId(0);
setFeaturedPreview('');
setOrientation('landscape');
setPageSize('a4');
setPageFinish(DEFAULT_CERTIFICATE_PAGE_FINISH);
setLayout(defaultCertificateLayout());
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setStatus(p.status || 'draft');
const fm = typeof p.featured_media === 'number' ? p.featured_media : 0;
setFeaturedId(fm);
const m = p.meta as Record | undefined;
const o = String(readMeta(m, '_sikshya_certificate_orientation') || 'landscape');
setOrientation(o === 'portrait' ? 'portrait' : 'landscape');
const s = String(readMeta(m, '_sikshya_certificate_page_size') || 'a4');
setPageSize(s === 'a4' ? 'a4' : s === 'a5' ? 'a5' : 'letter');
setPageFinish(
parseCertificatePageFinish(
readMeta(m, '_sikshya_certificate_page_color'),
readMeta(m, '_sikshya_certificate_page_pattern'),
readMeta(m, '_sikshya_certificate_page_deco')
)
);
const rawLayout = readMeta(m, '_sikshya_certificate_layout');
setLayout(parseLayoutFromMeta(rawLayout));
}, [editor.post, editor.isNew]);
const publicPreviewHref = useMemo(() => {
return String((editor.post as any)?.sikshya_certificate_preview_url || '').trim();
}, [editor.post]);
// The Free build seeds two ready-to-use templates and locks them against
// deletion via TemplateGuard. Mirror that lock in the UI so the affordance
// is hidden — the Pro filter is the single source of truth at the boundary.
const isProtectedTemplate = useMemo(() => {
const m = editor.post?.meta as Record | undefined;
if (!m) {
return false;
}
const locked = readMeta(m, '_sikshya_certificate_default_locked');
if (locked === true || locked === '1' || locked === 1) {
return true;
}
return readMeta(m, '_sikshya_certificate_default') === '1';
}, [editor.post]);
useEffect(() => {
if (editor.isNew || featuredId <= 0) {
if (!editor.isNew && featuredId <= 0) {
setFeaturedPreview('');
}
return;
}
let cancelled = false;
void getWpApi()
.get<{ source_url?: string }>(`/media/${featuredId}`)
.then((media) => {
if (!cancelled && media?.source_url) {
setFeaturedPreview(media.source_url);
}
})
.catch(() => {
if (!cancelled) {
setFeaturedPreview('');
}
});
return () => {
cancelled = true;
};
}, [featuredId, editor.isNew]);
// Auto-dismiss handled by shared toast.
const onSave = async (nextStatus?: string) => {
toast.clear();
editor.setError(null);
const targetStatus = nextStatus || status;
const latestLayout = layoutRef.current;
const generated = layoutToHtml(latestLayout, {
pageAspect: getPageAspectCss(orientation, pageSize),
pageColor: pageFinish.pageColor,
pagePattern: pageFinish.pagePattern,
pageDeco: pageFinish.pageDeco,
pageFeaturedImageUrl: featuredPreview,
});
const body: Record = {
title,
content: generated,
status: targetStatus,
featured_media: featuredId > 0 ? featuredId : 0,
meta: {
_sikshya_certificate_orientation: orientation,
_sikshya_certificate_page_size: pageSize,
_sikshya_certificate_page_color: pageFinish.pageColor,
_sikshya_certificate_page_pattern: pageFinish.pagePattern,
_sikshya_certificate_page_deco: pageFinish.pageDeco,
_sikshya_certificate_layout: layoutToStorage(latestLayout),
},
};
try {
const res = await editor.save(body);
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return;
}
}
// Reload the post in edit context so verification runs against the authoritative
// persisted state (not the save response), and the UI reflects exactly what's on disk.
let persistedMeta: Record | undefined;
try {
await editor.load();
// We can't synchronously read the just-updated editor.post here (React state).
// Fallback to the save response meta (save now uses ?context=edit, so it's populated).
persistedMeta =
(res && typeof res === 'object' && 'meta' in res
? (res as { meta?: Record }).meta
: undefined) ?? undefined;
} catch {
persistedMeta =
(res && typeof res === 'object' && 'meta' in res
? (res as { meta?: Record }).meta
: undefined) ?? undefined;
toast.error(__('Saved, but could not reload', 'sikshya'), 'Refresh this page to see the latest version.');
return;
}
// Verify persisted meta. WP sanitizers may coerce/strip values; when that happens, surface it.
// If persistedMeta is entirely missing from the response (unexpected now that we request
// context=edit), skip verification rather than raise a false alarm — the reload above
// already re-synchronised the UI with the server.
const expectedMeta = (body.meta || {}) as Record;
const expectedLayout = layoutRef.current;
const expectedBlockCount = Array.isArray(expectedLayout.blocks) ? expectedLayout.blocks.length : 0;
const mismatchedKeys: string[] = [];
if (persistedMeta && typeof persistedMeta === 'object') {
for (const k of Object.keys(expectedMeta)) {
const exp = expectedMeta[k];
const got = readMeta(persistedMeta, k);
if (k === '_sikshya_certificate_layout') {
// Layout is stored as a JSON string and may be re-encoded server-side.
// Validate that block count matches what we sent (sanitizer may drop unknown blocks).
const parsed = parseLayoutFromMeta(got);
const gotBlocks = Array.isArray(parsed?.blocks) ? parsed.blocks.length : 0;
if (expectedBlockCount > 0 && gotBlocks !== expectedBlockCount) {
mismatchedKeys.push(k);
}
continue;
}
if (k === '_sikshya_certificate_page_pattern') {
// PHP stores sanitize_key() slugs (lowercase); UI uses camelCase (e.g. microDots → microdots).
if (certificatePagePatternStoredValue(String(exp ?? '')) !== certificatePagePatternStoredValue(String(got ?? ''))) {
mismatchedKeys.push(k);
}
continue;
}
if (String(got ?? '') !== String(exp ?? '')) {
mismatchedKeys.push(k);
}
}
}
if (mismatchedKeys.length) {
toast.error(__('Some settings could not be saved', 'sikshya'),
mismatchedKeys.map((k) => k.replace(/^_sikshya_certificate_/, '')).join(', ')
);
} else {
toast.success(targetStatus === 'publish' ? __('Certificate published', 'sikshya') : __('Saved', 'sikshya'));
}
} catch {
return;
}
};
const openPreview = async () => {
const win = window.open('', '_blank', 'noopener,noreferrer');
if (!win) {
return;
}
if (!postId || postId <= 0) {
const errHtml = `
{__('Preview unavailable', 'sikshya')}
{__('Save the certificate first.', 'sikshya')} Preview needs a saved certificate so the server can authorize the request.
`;
win.document.open();
win.document.write(errHtml);
win.document.close();
return;
}
const loadingHtml = `
{__('Certificate preview', 'sikshya')}
{__('Loading certificate preview…', 'sikshya')}
{__('Generating a clean full-page preview (no theme styling).', 'sikshya')}
`;
win.document.open();
win.document.write(loadingHtml);
win.document.close();
try {
const resp = await getSikshyaApi().post<{ ok?: boolean; html?: string; title?: string }>(
`/admin/certificates/${encodeURIComponent(String(postId))}/preview`,
{
html: layoutToHtml(layoutRef.current, {
pageAspect: getPageAspectCss(orientation, pageSize),
pageColor: pageFinish.pageColor,
pagePattern: pageFinish.pagePattern,
pageDeco: pageFinish.pageDeco,
pageFeaturedImageUrl: featuredPreview,
}),
title: title.trim() || 'Certificate preview',
}
);
const raw = resp?.html ? String(resp.html) : '';
const t = resp?.title ? String(resp.title) : 'Certificate preview';
if (!raw.trim()) {
const emptyHtml = `
{__('Empty preview', 'sikshya')}
{__('No layout HTML was returned.', 'sikshya')}
{__('Add blocks on the canvas, save, and try preview again.', 'sikshya')}
`;
win.document.open();
win.document.write(emptyHtml);
win.document.close();
return;
}
const ar = getPageAspectCss(orientation, pageSize);
const previewHtml = `
${t.replace(/
`;
win.document.open();
win.document.write(previewHtml);
win.document.close();
} catch (e) {
const errHtml = `
{__('Preview failed', 'sikshya')}
{__('Could not load preview.', 'sikshya')} {__('Please try saving the certificate and opening preview again.', 'sikshya')}
`;
win.document.open();
win.document.write(errHtml);
win.document.close();
}
};
// When navigated from list row “Preview”, auto-open preview once after load.
const didAutoPreviewRef = useRef(false);
useEffect(() => {
if (didAutoPreviewRef.current) {
return;
}
if (String(config?.query?.open_preview || '') !== '1') {
return;
}
if (editor.isNew || !editor.post) {
return;
}
didAutoPreviewRef.current = true;
// Prefer public preview link (not blocked by popup rules).
if (publicPreviewHref) {
window.open(publicPreviewHref, '_blank', 'noopener,noreferrer');
return;
}
// Fallback: internal preview window (HTML generated from current layout).
window.setTimeout(() => void openPreview(), 50);
}, [config?.query?.open_preview, editor.isNew, editor.post]);
return (
void editor.load()}
saveMsg={null}
flush
>
← Back
{status === 'publish' ? __('Published', 'sikshya') : __('Draft', 'sikshya')}
{isProtectedTemplate ? (
Default template
) : null}
{title.trim() || 'Untitled certificate'}
{isProtectedTemplate
? 'Built-in template — edit freely, deletion is protected.'
: 'Full-page certificate builder workspace.'}
{!editor.isNew ? (
publicPreviewHref ? (
Preview
) : (
void openPreview()}
title={__('Opens an internal layout preview window. Save once to enable the public preview link.', 'sikshya')}
className="px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
Preview
)
) : null}
{!editor.isNew && status !== 'publish' ? (
void onSave('publish')}
className="bg-emerald-50 px-3.5 py-1.5 text-sm font-semibold text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 dark:bg-emerald-950/30 dark:text-emerald-300"
>
{editor.saving ? __('Publishing…', 'sikshya') : __('Publish', 'sikshya')}
) : null}
void onSave()} className="px-4 py-1.5">
{editor.saving ? __('Saving…', 'sikshya') : __('Save', 'sikshya')}
{!editor.isNew && !isProtectedTemplate ? (
Move to trash
) : null}
addon.enable()}
addonError={addon.error}
>
setFeaturedId(id)}
templatePreviewUrl={publicPreviewHref}
/>
{embedded ? (
void onSave()} />
) : null}
);
}
export function DefaultContentEditor(props: ContentEditorProps) {
const { postId, backHref, entityLabel, onSavedNewId, embedded } = props;
const rest = props.postType.replace(/^\//, '');
const editor = useWpContentPost(rest, postId);
const moveToTrash = useMoveToTrash(editor, backHref, entityLabel);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [featured, setFeatured] = useState(0);
const [saveMsg, setSaveMsg] = useState(null);
const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content');
useEffect(() => {
if (editor.isNew) {
setTitle('');
setContent('');
setStatus('draft');
setFeatured(0);
return;
}
if (!editor.post) {
return;
}
const p = editor.post;
setTitle(titleFromPost(p));
setContent(contentFromPost(p));
setStatus(p.status || 'draft');
setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0);
}, [editor.post, editor.isNew]);
const onSave = async (): Promise => {
setSaveMsg(null);
editor.setError(null);
try {
const res = await editor.save({
title,
content,
status,
featured_media: featured > 0 ? featured : 0,
});
if (editor.isNew && res && typeof res === 'object' && 'id' in res) {
const id = (res as { id: number }).id;
if (typeof id === 'number' && id > 0) {
onSavedNewId?.(id);
return true;
}
}
setSaveMsg(__('Saved.', 'sikshya'));
await editor.load();
return true;
} catch {
/* hook */
return false;
}
};
useEffect(() => {
if (!embedded || !props.exposeSave) {
return;
}
props.exposeSave(onSave);
return () => props.exposeSave?.(null);
}, [embedded, props.exposeSave, title, content, status, featured]);
return (
void editor.load()}
saveMsg={saveMsg}
>
setEditorTab(id as 'content' | 'settings')}
/>
{entityLabel}
Content: title, body, and optional featured image. Settings: publish status.
{editorTab === 'content' ? (
) : (
Status
{__('Draft hides this item from public views until you publish.', 'sikshya')}
setStatus(e.target.value)}>
{__('Draft', 'sikshya')}
{__('Published', 'sikshya')}
{__('Private', 'sikshya')}
)}
{embedded ? (
void onSave()} />
) : (
void onSave()}
onTrash={moveToTrash}
/>
)}
);
}
export function renderContentEditor(postType: string, props: ContentEditorProps): React.ReactNode {
switch (postType) {
case 'sik_lesson':
return ;
case 'sik_quiz':
return ;
case 'sik_question':
return ;
case 'sik_assignment':
return ;
case 'sik_chapter':
return ;
case 'sikshya_certificate':
return ;
default:
return ;
}
}