import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { getSikshyaApi } from '../../api'; import { SIKSHYA_ENDPOINTS } from '../../api/endpoints'; import { useAddonEnabled } from '../../hooks/useAddons'; import { DateTimePickerField } from '../../components/shared/DateTimePickerField'; import { __ } from '../../lib/i18n'; const FIELD = 'block w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'; const LABEL = 'mb-1 block text-sm font-medium text-slate-900 dark:text-slate-100'; const HINT = 'mb-2 text-xs text-slate-500 dark:text-slate-400'; const LIVE_PROVIDER_OPTIONS: { value: string; label: string }[] = [ { value: 'zoom', label: 'Zoom' }, { value: 'google_meet', label: 'Google Meet' }, { value: 'teams', label: 'Microsoft Teams' }, { value: 'webex', label: 'Webex' }, { value: 'classroom', label: 'Google Classroom' }, { value: 'jitsi', label: 'Jitsi' }, { value: 'custom', label: 'Other / custom' }, ]; function ProCard(props: { title: string; description?: string; badge?: string; children: ReactNode }) { const { title, description, badge, children } = props; return (

{title}

{badge ? ( {badge} ) : null}
{description ?

{description}

: null}
{children}
); } // --------------------------------------------------------------------------- // Lesson — live_classes + scorm_h5p_pro // --------------------------------------------------------------------------- /** Per-lesson launch mode override; '' = inherit from course/global policy. */ export type InteractiveLaunchMode = '' | 'inline' | 'fullscreen' | 'new_window'; export type ProLessonValues = { liveUrl: string; liveProvider: string; liveStart: string; liveDuration: number; liveSessionTitle: string; livePasscodeHint: string; liveRecordingUrl: string; /** Managed SCORM package id from the package library (0 = none). */ scormPackageId: number; /** Optional external SCORM launch URL (used only if no managed package is attached). */ scormUrl: string; /** Selected H5P content id from the picker (0 = none). */ h5pContentId: number; /** Sanitized iframe HTML fallback for environments without the H5P plugin. */ h5pEmbed: string; /** Per-lesson player display override; empty string means inherit. */ launchMode: InteractiveLaunchMode; }; export const PRO_LESSON_DEFAULTS: ProLessonValues = { liveUrl: '', liveProvider: '', liveStart: '', liveDuration: 60, liveSessionTitle: '', livePasscodeHint: '', liveRecordingUrl: '', scormPackageId: 0, scormUrl: '', h5pContentId: 0, h5pEmbed: '', launchMode: '', }; /** * ISO strings come back from WP as "2026-04-19T14:00:00+00:00". * datetime-local inputs want "2026-04-19T14:00". */ function isoToLocal(iso: string): string { if (!iso) return ''; try { const d = new Date(iso); if (isNaN(d.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } catch { return ''; } } function localToIso(local: string): string { if (!local) return ''; try { const d = new Date(local); if (isNaN(d.getTime())) return ''; return d.toISOString(); } catch { return ''; } } export function readProLessonFromMeta(meta: Record | undefined): ProLessonValues { const m = meta ?? {}; const launch = String(m['_sikshya_lesson_launch_mode'] ?? ''); const launchMode: InteractiveLaunchMode = launch === 'inline' || launch === 'fullscreen' || launch === 'new_window' ? launch : ''; return { liveUrl: String(m['_sikshya_live_meeting_url'] ?? ''), liveProvider: String(m['_sikshya_live_provider'] ?? ''), liveStart: isoToLocal(String(m['_sikshya_live_start_at'] ?? '')), liveDuration: Number(m['_sikshya_live_duration_minutes'] ?? 60) || 60, liveSessionTitle: String(m['_sikshya_live_session_title'] ?? ''), livePasscodeHint: String(m['_sikshya_live_passcode_hint'] ?? ''), liveRecordingUrl: String(m['_sikshya_live_recording_url'] ?? ''), scormPackageId: Math.max(0, Number(m['_sikshya_scorm_package_id'] ?? 0) || 0), scormUrl: String(m['_sikshya_scorm_launch_url'] ?? ''), h5pContentId: Math.max(0, Number(m['_sikshya_h5p_content_id'] ?? 0) || 0), h5pEmbed: String(m['_sikshya_h5p_embed_html'] ?? ''), launchMode, }; } /** * Lesson kinds that carry Pro-only field sets. Aligned with `_sikshya_lesson_type`. */ export type ProLessonKind = 'live' | 'scorm' | 'h5p'; /** * Persist only the meta keys relevant to the active lesson kind. Keys for other * kinds are explicitly emptied so switching from "Live class" to "SCORM" does * not leave a stale meeting URL behind that the runtime renderer would still * pick up. */ export function buildProLessonMetaForKind( kind: string, v: ProLessonValues ): Record { const isLive = kind === 'live'; const isScorm = kind === 'scorm'; const isH5p = kind === 'h5p'; const isInteractive = isScorm || isH5p; return { _sikshya_live_meeting_url: isLive ? v.liveUrl.trim() : '', _sikshya_live_provider: isLive ? v.liveProvider : '', _sikshya_live_start_at: isLive && v.liveStart ? localToIso(v.liveStart) : '', _sikshya_live_duration_minutes: isLive ? Math.max(0, Math.min(720, Number(v.liveDuration) || 0)) : 0, _sikshya_scorm_package_id: isScorm ? Math.max(0, Number(v.scormPackageId) || 0) : 0, _sikshya_scorm_launch_url: isScorm ? v.scormUrl.trim() : '', _sikshya_h5p_content_id: isH5p ? Math.max(0, Number(v.h5pContentId) || 0) : 0, _sikshya_h5p_embed_html: isH5p ? v.h5pEmbed : '', _sikshya_lesson_launch_mode: isInteractive ? v.launchMode : '', }; } /** * @deprecated Prefer {@link buildProLessonMetaForKind} so unrelated meta is cleared. * Kept for back-compat with classic admin tooling that always saves the full set. */ export function buildProLessonMeta(v: ProLessonValues): Record { return { _sikshya_live_meeting_url: v.liveUrl.trim(), _sikshya_live_provider: v.liveProvider, _sikshya_live_start_at: v.liveStart ? localToIso(v.liveStart) : '', _sikshya_live_duration_minutes: Math.max(5, Math.min(720, Number(v.liveDuration) || 0)), _sikshya_live_session_title: v.liveSessionTitle.trim(), _sikshya_live_passcode_hint: v.livePasscodeHint.trim(), _sikshya_live_recording_url: v.liveRecordingUrl.trim(), _sikshya_scorm_package_id: Math.max(0, Number(v.scormPackageId) || 0), _sikshya_scorm_launch_url: v.scormUrl.trim(), _sikshya_h5p_content_id: Math.max(0, Number(v.h5pContentId) || 0), _sikshya_h5p_embed_html: v.h5pEmbed, _sikshya_lesson_launch_mode: v.launchMode, }; } /** * Live class field block — rendered when the active lesson kind is "live" * AND the `live_classes` Pro addon is licensed + enabled. * * Emits a non-null upsell card when the kind is selected but the addon is off, * so admins always understand why the fields are missing. */ export function ProLessonLiveBlock(props: { values: ProLessonValues; onChange: (v: ProLessonValues) => void; }) { const { values, onChange } = props; const addon = useAddonEnabled('live_classes'); const ready = Boolean(addon.enabled && addon.licenseOk); const set = (k: K, val: ProLessonValues[K]) => onChange({ ...values, [k]: val }); if (!ready) { return (

The lesson is set to “Live class” but the addon is unavailable right now — fields will appear here once it is enabled.

); } return (
set('liveUrl', e.target.value)} placeholder="https://zoom.us/j/..." />

Must be an https:// link. Learners only see this after they can access the lesson.

set('liveStart', v)} className="" />

Drives the course schedule strip, learn banner, catalog “Live” badge window, and calendar export.

set('liveDuration', Number(e.target.value) || 0)} />
set('liveSessionTitle', e.target.value)} placeholder={__('Week 2 · Design critique', 'sikshya')} />

{__('Shown in schedules and calendar instead of the lesson title when set.', 'sikshya')}

set('livePasscodeHint', e.target.value)} placeholder={__('ID: 123 456 7890', 'sikshya')} />

{__('Plain text only. Never store secrets you would not email to students.', 'sikshya')}

set('liveRecordingUrl', e.target.value)} placeholder="https://…" />

{__('Cloud replay link appears under the join panel when populated.', 'sikshya')}

); } // --------------------------------------------------------------------------- // SCORM/H5P attach wizard helpers // --------------------------------------------------------------------------- type ScormPackageRow = { id: number; title: string; scorm_version?: string; status: string; file_size_bytes?: number; lesson_reference_count?: number; manifest_identifier?: string; launch_path?: string; updated_at: string; }; type ScormPackagesResponse = { rows?: ScormPackageRow[]; total?: number; ok?: boolean; }; type H5pContentRow = { id: number; title: string; library: string; updated_at: string; }; type H5pContentsResponse = { ok?: boolean; plugin_available?: boolean; rows?: H5pContentRow[]; }; const LAUNCH_MODE_OPTIONS: ReadonlyArray<{ value: InteractiveLaunchMode; label: string; help: string }> = [ { value: '', label: 'Use course / global default', help: 'Inherits launch mode from the course or addon settings.' }, { value: 'inline', label: 'Inline iframe', help: 'Embeds the player inside the lesson page.' }, { value: 'fullscreen', label: 'Inline + fullscreen toggle', help: 'Inline by default with a fullscreen control in the player toolbar.' }, { value: 'new_window', label: 'Open in new window', help: 'Launches the player in a popup; useful when the package blocks iframing.' }, ]; function formatSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return '—'; const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = bytes; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; } /** * SCORM lesson attach wizard — rendered when the active lesson kind is "scorm". * Gated by the `scorm_h5p_pro` addon. Lets the instructor pick a managed package * from the library, or fall back to an external launch URL. * * Model (aligned with typical LMS activity placement): one Sikshya **lesson** = one SCORM * launch on Learn. A zip may contain many internal SCOs; that sequencing stays inside * the runtime. Course settings only provide defaults across all interactive lessons. */ export function ProLessonScormBlock(props: { values: ProLessonValues; onChange: (v: ProLessonValues) => void; }) { const { values, onChange } = props; const addon = useAddonEnabled('scorm_h5p_pro'); const ready = Boolean(addon.enabled && addon.licenseOk); const set = (k: K, val: ProLessonValues[K]) => onChange({ ...values, [k]: val }); const [search, setSearch] = useState(''); const [packages, setPackages] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!ready) return; let cancelled = false; setLoading(true); setError(null); const t = setTimeout(() => { const api = getSikshyaApi(); api .get(SIKSHYA_ENDPOINTS.pro.scormPackages({ per_page: 25, search })) .then((res) => { if (cancelled) return; setPackages(Array.isArray(res?.rows) ? res.rows : []); }) .catch((err: unknown) => { if (cancelled) return; setPackages([]); setError(err instanceof Error ? err.message : 'Could not load packages.'); }) .finally(() => { if (!cancelled) setLoading(false); }); }, 250); return () => { cancelled = true; clearTimeout(t); }; }, [ready, search]); const selected = useMemo( () => packages.find((p) => p.id === values.scormPackageId) || null, [packages, values.scormPackageId] ); if (!ready) { return (

The lesson is set to “SCORM” but the addon is unavailable right now — fields will appear here once it is enabled.

); } const hasSelection = values.scormPackageId > 0; return (

Upload zipped packages on the SCORM / H5P workspace, then attach here. Multiple curriculum lessons may reuse the same library package (lesson attempts are tracked per learner per lesson).

setSearch(e.target.value)} placeholder={__('Search packages by title…', 'sikshya')} aria-label={__('Search SCORM packages', 'sikshya')} />
{loading ? (

{__('Loading library…', 'sikshya')}

) : error ? (

{error}

) : packages.length === 0 ? (

No packages found. Upload one on the {__('SCORM / H5P', 'sikshya')} workspace, then refresh this picker.

) : (
  • {packages.map((p) => { const isActive = p.id === values.scormPackageId; const disabled = p.status !== 'ready'; const refCount = p.lesson_reference_count ?? 0; return (
  • ); })}
)}
{selected ? (

Selected: {selected.title || `Package #${selected.id}`} · entry {selected.launch_path || '—'}

) : null}
set('scormUrl', e.target.value)} placeholder="https://example.com/scorm/index_lms.html" disabled={hasSelection} />

{hasSelection ? 'Disabled while a managed package is attached — clear the selection above to use an external URL.' : 'Direct link to the unzipped SCORM entry HTML (the file referenced by imsmanifest.xml). Used only when no managed package is selected.'}

set('launchMode', v)} />
); } /** * H5P lesson attach wizard — rendered when the active lesson kind is "h5p". * Gated by the `scorm_h5p_pro` addon. Surfaces the H5P content picker when the * H5P plugin is installed; otherwise falls back to a sanitized iframe embed. * * Like SCORM: one lesson row = one H5P playback context on Learn; course tab sets defaults. */ export function ProLessonH5pBlock(props: { values: ProLessonValues; onChange: (v: ProLessonValues) => void; }) { const { values, onChange } = props; const addon = useAddonEnabled('scorm_h5p_pro'); const ready = Boolean(addon.enabled && addon.licenseOk); const set = (k: K, val: ProLessonValues[K]) => onChange({ ...values, [k]: val }); const [search, setSearch] = useState(''); const [contents, setContents] = useState([]); const [available, setAvailable] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!ready) return; let cancelled = false; setLoading(true); setError(null); const t = setTimeout(() => { const api = getSikshyaApi(); api .get(SIKSHYA_ENDPOINTS.pro.h5pContents({ per_page: 25, search })) .then((res) => { if (cancelled) return; setAvailable(Boolean(res?.plugin_available)); setContents(Array.isArray(res?.rows) ? res.rows : []); }) .catch((err: unknown) => { if (cancelled) return; setAvailable(false); setContents([]); setError(err instanceof Error ? err.message : 'Could not load H5P content.'); }) .finally(() => { if (!cancelled) setLoading(false); }); }, 250); return () => { cancelled = true; clearTimeout(t); }; }, [ready, search]); const selected = useMemo( () => contents.find((c) => c.id === values.h5pContentId) || null, [contents, values.h5pContentId] ); const hasSelection = values.h5pContentId > 0; if (!ready) { return (

The lesson is set to “H5P” but the addon is unavailable right now — fields will appear here once it is enabled.

); } return ( {available ? (

Selecting content auto-renders it via the H5P shortcode and tracks results in reports.

setSearch(e.target.value)} placeholder={__('Search H5P content by title…', 'sikshya')} aria-label={__('Search H5P content', 'sikshya')} />
{loading ? (

{__('Loading content…', 'sikshya')}

) : error ? (

{error}

) : contents.length === 0 ? (

No H5P content yet. Create some inside the H5P plugin, then refresh this picker.

) : (
  • {contents.map((c) => { const isActive = c.id === values.h5pContentId; return (
  • ); })}
)}
{selected ? (

Selected: {selected.title || `Content #${selected.id}`} {selected.library ? <> · {selected.library} : null}

) : null}
) : null}