import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { BulkActionsBar } from '../components/shared/list/BulkActionsBar'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListSearchToolbar } from '../components/shared/list/ListSearchToolbar'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { CourseFilterSelect } from '../components/shared/CourseFilterSelect'; import { FieldHint } from '../components/shared/FieldHint'; import { DataTable, type Column } from '../components/shared/DataTable'; import { DataTableSkeleton } from '../components/shared/Skeleton'; import { DateTimePickerField } from '../components/shared/DateTimePickerField'; import { Modal } from '../components/shared/Modal'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { ListPaginationBar } from '../components/shared/list/ListPaginationBar'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAsyncData } from '../hooks/useAsyncData'; import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import { appViewHref } from '../lib/appUrl'; import type { SikshyaReactConfig } from '../types'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { __ } from '../lib/i18n'; /** Mirrors `DripService::unlockAtForLesson` — keep the union in sync with the PHP side. */ type DripRuleType = 'delay_days' | 'date'; type Rule = { id?: number; course_id?: number; lesson_id?: number | null; rule_type?: string; rule_value?: string; created_at?: string; course_title?: string | null; lesson_title?: string | null; }; type ListResp = { ok?: boolean; rules?: Rule[]; total?: number; page?: number; per_page?: number; total_pages?: number }; type LessonRow = { id: number; title: string; status: string }; type LessonsResp = { ok?: boolean; course_id?: number; lessons?: LessonRow[] }; type DripNotifStatus = { ok?: boolean; drip_addon_enabled?: boolean; drip_notifications_addon_enabled?: boolean; drip_notification_mode?: 'per_lesson' | 'digest' | string; next_drip_run_unix?: number; next_drip_run_iso?: string; note?: string; template_lesson_unlock_enabled?: boolean; template_course_unlock_enabled?: boolean; template_lesson_digest_enabled?: boolean; lesson_unlock_email_active?: boolean; course_unlock_email_active?: boolean; lesson_digest_email_active?: boolean; }; /** * Render a saved rule's `rule_value` in plain English so admins recognise their * own rule in the list without parsing raw values. Falls back to the raw value * if the type is unknown, so future rule kinds still surface something. */ function describeRuleValue(type: string, value: string): string { if (type === 'delay_days') { const days = parseInt(value, 10); if (!Number.isFinite(days) || days <= 0) { return 'Immediately on enrollment'; } return `${days} day${days === 1 ? '' : 's'} after enrollment`; } if (type === 'date') { const ts = Date.parse(value); if (!Number.isFinite(ts)) { return value || '—'; } try { return `On ${new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(ts))}`; } catch { return value || '—'; } } return value || '—'; } function ruleRowId(r: Rule): number { const n = Number(r.id); return Number.isFinite(n) && n > 0 ? n : 0; } const dripToolbarSelectClass = 'rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200'; export function ContentDripPage(props: { config: SikshyaReactConfig; title: string; embedded?: boolean }) { const { config, title, embedded } = props; const dialog = useSikshyaDialog(); const featureOk = isFeatureEnabled(config, 'content_drip'); const addon = useAddonEnabled('content_drip'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const notifyFeature = isFeatureEnabled(config, 'drip_notifications'); const notifyAddon = useAddonEnabled('drip_notifications'); const notifyMode = resolveGatedWorkspaceMode(notifyFeature, notifyAddon.enabled, notifyAddon.loading); const notifyEnabled = notifyMode === 'full'; // Initial course id from the URL so deep links from the curriculum builder // (?view=learning-rules&tab=drip&course_id=NN) land directly on Step 2. const initialCourse = config.query?.course_id ? Number(config.query.course_id) : 0; const [courseId, setCourseId] = useState(Number.isFinite(initialCourse) ? initialCourse : 0); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 320); const [listRuleType, setListRuleType] = useState(''); const [page, setPage] = useState(1); const perPage = 20; const [listOrderBy, setListOrderBy] = useState<'created_at' | 'course' | 'id'>('created_at'); const [listOrder, setListOrder] = useState<'asc' | 'desc'>('desc'); const [selectedRuleIds, setSelectedRuleIds] = useState>(() => new Set()); const [bulkActionValue, setBulkActionValue] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const [bulkError, setBulkError] = useState(null); const headerSelectRef = useRef(null); const [editorOpen, setEditorOpen] = useState(false); const [editing, setEditing] = useState(null); const toast = useTopRightToast(2400); const [deletingId, setDeletingId] = useState(null); // Auto-dismiss handled by shared toast. useEffect(() => { setPage(1); }, [debouncedSearch, courseId, listRuleType, listOrderBy, listOrder]); useEffect(() => { setSelectedRuleIds(new Set()); setBulkActionValue(''); }, [page, courseId, debouncedSearch, listRuleType, listOrderBy, listOrder]); const rulesLoader = useCallback(async () => { if (!enabled) { return { ok: true, rules: [] as Rule[], total: 0, page: 1, per_page: perPage, total_pages: 0 } as ListResp; } const q = new URLSearchParams({ page: String(page), per_page: String(perPage), orderby: listOrderBy, order: listOrder, ...(courseId > 0 ? { course_id: String(courseId) } : {}), ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), ...(listRuleType ? { rule_type: listRuleType } : {}), }); return getSikshyaApi().get(`${SIKSHYA_ENDPOINTS.pro.dripRules}?${q.toString()}`); }, [enabled, courseId, debouncedSearch, listRuleType, page, perPage, listOrderBy, listOrder]); const rulesQ = useAsyncData(rulesLoader, [enabled, courseId, debouncedSearch, listRuleType, page, listOrderBy, listOrder, perPage]); const rules = rulesQ.data?.rules ?? []; const dripBulkOptions = useMemo(() => [{ value: 'delete_schedules', label: 'Delete schedules' }], []); const selectableIdsOnPage = useMemo(() => rules.map(ruleRowId).filter((id) => id > 0), [rules]); const checkedOnPage = useMemo( () => selectableIdsOnPage.filter((id) => selectedRuleIds.has(id)).length, [selectableIdsOnPage, selectedRuleIds] ); const allVisibleSelected = selectableIdsOnPage.length > 0 && checkedOnPage === selectableIdsOnPage.length; useLayoutEffect(() => { const el = headerSelectRef.current; if (!el) return; el.indeterminate = checkedOnPage > 0 && checkedOnPage < selectableIdsOnPage.length; }, [checkedOnPage, selectableIdsOnPage.length]); const toggleSelectAllRules = useCallback(() => { setSelectedRuleIds((prev) => { const next = new Set(prev); const allSel = selectableIdsOnPage.length > 0 && selectableIdsOnPage.every((id) => next.has(id)); if (allSel) { selectableIdsOnPage.forEach((id) => next.delete(id)); } else { selectableIdsOnPage.forEach((id) => next.add(id)); } return next; }); }, [selectableIdsOnPage]); const toggleRuleSelected = useCallback((id: number) => { if (id <= 0) return; setSelectedRuleIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const [editorCourseId, setEditorCourseId] = useState(0); const [editorScope, setEditorScope] = useState<'course' | 'lesson'>('course'); const [editorLessonId, setEditorLessonId] = useState(0); const [editorRuleType, setEditorRuleType] = useState('delay_days'); const [editorDelayDays, setEditorDelayDays] = useState('7'); const [editorUnlockDate, setEditorUnlockDate] = useState(''); const [editorSaving, setEditorSaving] = useState(false); const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1); useEffect(() => { if (!editorOpen) { return; } const cid = editing?.course_id ? Number(editing.course_id) : courseId > 0 ? courseId : 0; const lid = editing?.lesson_id != null ? Number(editing.lesson_id) : 0; const scope = Number.isFinite(lid) && lid > 0 ? 'lesson' : 'course'; const rt = (editing?.rule_type as DripRuleType | undefined) || 'delay_days'; setEditorCourseId(Number.isFinite(cid) ? cid : 0); setEditorScope(scope); setEditorLessonId(Number.isFinite(lid) && lid > 0 ? lid : 0); setEditorRuleType(rt); setEditorDelayDays(rt === 'delay_days' ? String(editing?.rule_value ?? '7') : '7'); setEditorUnlockDate(rt === 'date' ? String(editing?.rule_value ?? '') : ''); setEditorStep(cid > 0 ? 2 : 1); }, [editorOpen, editing, courseId]); const editorLessonsLoader = useCallback(async () => { if (!enabled || editorCourseId <= 0) { return { ok: true, course_id: editorCourseId, lessons: [] } as LessonsResp; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.courseLessons(editorCourseId)); }, [enabled, editorCourseId]); const editorLessonsQ = useAsyncData(editorLessonsLoader, [enabled, editorCourseId]); const editorLessons = editorLessonsQ.data?.lessons ?? []; const notifyLoader = useCallback(async () => { if (!notifyEnabled) return { ok: true } as DripNotifStatus; return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.dripNotificationsStatus); }, [notifyEnabled]); const notifyQ = useAsyncData(notifyLoader, [notifyEnabled]); const [dripEmailMode, setDripEmailMode] = useState<'per_lesson' | 'digest'>('per_lesson'); const [modeSaving, setModeSaving] = useState(false); useEffect(() => { const m = notifyQ.data?.drip_notification_mode; if (m === 'digest' || m === 'per_lesson') { setDripEmailMode(m); } }, [notifyQ.data?.drip_notification_mode]); const saveDripEmailMode = async () => { setModeSaving(true); toast.clear(); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.dripNotificationsSettings, { mode: dripEmailMode }); toast.success(__('Saved', 'sikshya'), 'Lesson notification mode saved.'); void notifyQ.refetch(); } catch (err) { toast.error(__('Save failed', 'sikshya'), err instanceof Error ? err.message : 'Could not save settings'); } finally { setModeSaving(false); } }; const saveRuleFromModal = async () => { const cid = editorCourseId; if (cid <= 0) { toast.error(__('Missing course', 'sikshya'), 'Pick the course first.'); return; } if (editorScope === 'lesson' && editorLessonId <= 0) { toast.error(__('Missing lesson', 'sikshya'), 'Pick the lesson this rule applies to.'); return; } let value = ''; if (editorRuleType === 'delay_days') { const days = parseInt(editorDelayDays, 10); if (!Number.isFinite(days) || days < 0) { toast.error(__('Missing value', 'sikshya'), 'Enter how many days after enrollment the lesson should unlock.'); return; } value = String(days); } else { if (!editorUnlockDate) { toast.error(__('Missing date', 'sikshya'), 'Pick the date when learners should get access.'); return; } value = editorUnlockDate; } setEditorSaving(true); toast.clear(); const payload = { course_id: cid, lesson_id: editorScope === 'lesson' ? editorLessonId : 0, rule_type: editorRuleType, rule_value: value, }; const existingId = editing?.id != null ? Number(editing.id) : NaN; const isEdit = Number.isFinite(existingId) && existingId > 0; try { if (isEdit) { await getSikshyaApi().put(SIKSHYA_ENDPOINTS.pro.dripRule(existingId), payload); } else { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.dripRules, payload); } toast.success(__('Saved', 'sikshya'), isEdit ? __('Schedule updated.', 'sikshya') : __('Schedule created.', 'sikshya')); setEditorOpen(false); setEditing(null); // List is filtered by toolbar course + rule type + search — align filters so the new row appears. setCourseId(cid); setPage(1); setSearch(''); setListRuleType(''); void rulesQ.refetch(); } catch (err) { toast.error(__('Save failed', 'sikshya'), err instanceof Error ? err.message : 'Save failed'); } finally { setEditorSaving(false); } }; const removeRule = useCallback( async (rule: Rule) => { if (!rule.id) return; const lid = rule.lesson_id ? Number(rule.lesson_id) : 0; const target = lid > 0 ? rule.lesson_title && String(rule.lesson_title).trim() ? rule.lesson_title : `Lesson #${lid}` : 'the whole course'; const ok = await dialog.confirm({ title: __('Remove this schedule?', 'sikshya'), message: ( The unlock rule for {target} will be deleted. Learners who haven't unlocked it yet will get immediate access (unless another rule covers the same lesson). ), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; setDeletingId(rule.id); toast.clear(); try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.pro.dripRule(rule.id)); toast.success(__('Removed', 'sikshya'), 'Schedule removed.'); void rulesQ.refetch(); } catch (err) { toast.error(__('Remove failed', 'sikshya'), err instanceof Error ? err.message : 'Could not delete the rule'); } finally { setDeletingId(null); } }, [dialog, rulesQ] ); const onDripBulkApply = useCallback(async () => { if (!enabled || selectedRuleIds.size === 0 || bulkActionValue !== 'delete_schedules') { return; } const ids = [...selectedRuleIds]; const n = ids.length; const ok = await dialog.confirm({ title: `Delete ${n} schedule(s)?`, message: __('Selected unlock rules will be removed. Learners who have not unlocked those lessons yet may get immediate access unless another rule applies.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; setBulkBusy(true); setBulkError(null); toast.clear(); try { await Promise.all(ids.map((id) => getSikshyaApi().delete(SIKSHYA_ENDPOINTS.pro.dripRule(id)))); toast.success(__('Removed', 'sikshya'), `Removed ${n} schedule(s).`); setSelectedRuleIds(new Set()); setBulkActionValue(''); void rulesQ.refetch(); } catch (e) { setBulkError(e); } finally { setBulkBusy(false); } }, [bulkActionValue, dialog, enabled, rulesQ, selectedRuleIds]); const columns: Column[] = useMemo( () => [ { id: '_bulk_select', header: ( ), alwaysVisible: true, headerClassName: 'w-12', cellClassName: 'w-12', render: (r) => { const id = ruleRowId(r); if (id <= 0) { return ; } return ( toggleRuleSelected(id)} /> ); }, }, { id: 'course', header: 'Course', render: (r) => (
{r.course_title || (r.course_id ? `Course #${r.course_id}` : '—')}
{r.course_id ?
#{r.course_id}
: null}
), }, { id: 'scope', header: 'Scope', cellClassName: 'whitespace-nowrap text-slate-600 dark:text-slate-300', render: (r) => (r.lesson_id ? __('Lesson', 'sikshya') : __('Course', 'sikshya')), }, { id: 'lesson', header: 'Lesson', render: (r) => r.lesson_id ? (
{r.lesson_title || `Lesson #${r.lesson_id}`}
) : ( ), }, { id: 'unlock', header: 'Unlock', render: (r) => ( {describeRuleValue(String(r.rule_type || ''), String(r.rule_value || ''))} ), }, { id: 'actions', header: '', headerClassName: 'w-[1%]', cellClassName: 'text-right align-middle', render: (r) => { const rid = r.id ?? 0; const busy = deletingId === rid; const items: RowActionItem[] = [ { key: 'edit', label: 'Edit schedule', onClick: () => { setEditing(r); setEditorOpen(true); }, }, { key: 'delete', label: busy ? __('Deleting…', 'sikshya') : __('Delete schedule', 'sikshya'), danger: true, disabled: rid <= 0 || busy, onClick: () => { if (busy || rid <= 0) return; void removeRule(r); }, }, ]; return (
e.stopPropagation()}>
); }, }, ], [ deletingId, removeRule, allVisibleSelected, selectedRuleIds, toggleSelectAllRules, toggleRuleSelected, selectableIdsOnPage.length, ] ); return ( addon.enable()} addonError={addon.error} >
(editorSaving ? null : (setEditorOpen(false), setEditing(null)))} size="lg" footer={
{editorStep < 3 ? ( { if (editorSaving) return; if (editorStep === 1) { if (editorCourseId <= 0) { toast.error(__('Missing course', 'sikshya'), 'Pick the course first.'); return; } setEditorStep(2); return; } if (editorScope === 'lesson' && editorLessonId <= 0) { toast.error(__('Missing lesson', 'sikshya'), 'Pick the lesson this schedule applies to.'); return; } setEditorStep(3); }} disabled={editorSaving} > Next ) : ( void saveRuleFromModal()} disabled={editorSaving}> {editorSaving ? 'Saving…' : editing ? __('Save changes', 'sikshya') : __('Create schedule', 'sikshya')} )}
} >
{(['Course', 'Scope', 'Unlock rule'] as const).map((label, idx) => { const step = (idx + 1) as 1 | 2 | 3; const active = step === editorStep; return ( {step}. {label} ); })}
{editorStep === 1 ? (
{ setEditorCourseId(id); setEditorLessonId(0); }} allowClear={false} fieldLayout="compact" dropdownZIndex={11050} allLabel="Search and select a course…" label="Course" hint="Pick the course you want to schedule. You can add multiple schedules per course." />
) : null} {editorStep === 2 ? (
) : null} {editorStep === 3 ? (
{editorRuleType === 'delay_days' ? (
) : (
)}
) : null}

{__('Schedules', 'sikshya')}

Same layout as Course listing: search, sort, bulk actions, then the table.

{ setEditing(null); setEditorOpen(true); }} > + Add schedule rule
setListOrderBy(v as 'created_at' | 'course' | 'id')} sortOrder={listOrder} onSortOrderToggle={() => setListOrder((o) => (o === 'asc' ? 'desc' : 'asc'))} trailing={
} />
void onDripBulkApply()} applyBusy={bulkBusy} trashMode={false} />
{rulesQ.data?.total != null ? `${rulesQ.data.total} schedule${rulesQ.data.total === 1 ? '' : 's'}` : '\u00a0'}
{bulkError ? (
setBulkError(null)} />
) : null} {rulesQ.error ? (
rulesQ.refetch()} />
) : rulesQ.loading ? ( ) : ( <> r.id || `${r.course_id}-${r.lesson_id || 0}-${r.rule_type || ''}`} wrapInCard={false} emptyContent={ { setEditing(null); setEditorOpen(true); }} > + Add schedule rule } /> } /> )}
{/* Disambiguation: this is the LMS-enforced schedule, not a content author's "release notes" field. Mirrors the equivalent block on PrerequisitesPage so the two screens read as a pair. */}

{__('What this page does', 'sikshya')}

These rules {__('actually block access', 'sikshya')} to a lesson until the unlock condition is met. A learner who tries to open a locked lesson will see "This lesson will unlock on …" instead of the content. Pair this with {__('Prerequisites', 'sikshya')} when you want learners to also finish prior lessons before unlocking.

{__('Quizzes and assignments', 'sikshya')} use the same unlock time as the {__('nearest previous lesson', 'sikshya')}{' '} in your course outline (or the course-wide rule if nothing comes before them). Schedule per-lesson overrides to control when later quizzes open in sequence.

{/* Drip notifications addon — sub-section, gated separately so admins can toggle the email-on-unlock behaviour without touching the schedule rules above. */}
notifyAddon.enable()} addonError={notifyAddon.error} >

Notification cron

Sikshya checks every hour for newly-unlocked lessons and sends templated email to affected learners. Configure copy under{' '} Email templates {' '} and toggles on the{' '} Email {' '} screen.

{notifyQ.error ? (
notifyQ.refetch()} />
) : notifyQ.loading ? (

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

) : (
Next run
{notifyQ.data?.next_drip_run_iso ? notifyQ.data.next_drip_run_iso : '—'}
Schedule addon
{notifyQ.data?.drip_addon_enabled ? __('Enabled', 'sikshya') : __('Disabled', 'sikshya')}
Lesson unlock email
{notifyQ.data?.lesson_unlock_email_active ? 'On' : notifyQ.data?.drip_notifications_addon_enabled === false ? 'Off (Drip notifications add-on)' : notifyQ.data?.template_lesson_unlock_enabled === false ? 'Off (template disabled)' : 'Off (plan)'}
Course-wide unlock email
{notifyQ.data?.course_unlock_email_active ? 'On' : notifyQ.data?.drip_notifications_addon_enabled === false ? 'Off (Drip notifications add-on)' : notifyQ.data?.template_course_unlock_enabled === false ? 'Off (template disabled)' : 'Off (plan)'}
Digest email (multi-lesson unlock)
{notifyQ.data?.lesson_digest_email_active ? 'On' : notifyQ.data?.drip_notifications_addon_enabled === false ? 'Off (Drip notifications add-on)' : notifyQ.data?.template_lesson_digest_enabled === false ? 'Off (template disabled)' : 'Off (plan)'}
void notifyQ.refetch()}> Refresh status void saveDripEmailMode()}> {modeSaving ? __('Saving…', 'sikshya') : __('Save mode', 'sikshya')}
{notifyQ.data?.note ? (

{notifyQ.data.note}

) : null} {!notifyQ.data?.drip_addon_enabled ? (

Notifications only fire when Scheduled access is also enabled above. Without it, nothing actually unlocks for learners and there is nothing to email about.

) : null}
)}
); }