import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { ButtonPrimary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { appViewHref } from '../lib/appUrl'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type InstructorRow = { id: number; course_id: number; user_id: number; role: string; revenue_share: number; display_name?: string; user_email?: string; avatar_url?: string; course_title?: string; }; type Resp = { ok?: boolean; instructors?: InstructorRow[]; share_total?: number; warnings?: string[]; }; type AllStaffResp = { ok?: boolean; rows?: InstructorRow[] }; type EarningsRow = { id: number; order_item_id: number; amount: number; status: string; created_at: string }; type EarningsResp = { ok?: boolean; rows?: EarningsRow[]; total?: number }; type UserOpt = { id: number; name: string; email?: string }; type CourseOpt = { id: number; title: string }; /** Matches course builder instructor picker (WP roles that can be assigned as staff). */ const STAFF_SEARCH_ROLES = ['administrator', 'editor', 'author', 'sikshya_instructor'] as const; export function CourseTeamPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const dialog = useSikshyaDialog(); const featureOk = isFeatureEnabled(config, 'multi_instructor'); const addon = useAddonEnabled('multi_instructor'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const qCourse = config.query?.course_id; const [courseId, setCourseId] = useState(qCourse || ''); const [courseQuery, setCourseQuery] = useState(''); const [courseDropdownOpen, setCourseDropdownOpen] = useState(false); const [newUserId, setNewUserId] = useState(''); const [userQuery, setUserQuery] = useState(''); const [userDropdownOpen, setUserDropdownOpen] = useState(false); const [share, setShare] = useState('0'); const [newMemberRole, setNewMemberRole] = useState<'co_instructor' | 'lead'>('co_instructor'); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [earningsUserId, setEarningsUserId] = useState(''); const [ledgerUserQuery, setLedgerUserQuery] = useState(''); const [ledgerDropdownOpen, setLedgerDropdownOpen] = useState(false); const [ledgerPickedLabel, setLedgerPickedLabel] = useState(''); const debouncedLedgerQuery = useDebouncedValue(ledgerUserQuery, 240); const [courseAuthorId, setCourseAuthorId] = useState(null); const [rowShareDraft, setRowShareDraft] = useState>({}); const [rowRoleDraft, setRowRoleDraft] = useState>({}); const [ledgerBusyId, setLedgerBusyId] = useState(null); const [staffFilter, setStaffFilter] = useState(''); const debouncedStaffFilter = useDebouncedValue(staffFilter, 200); const canManageLedger = Boolean(config.multiInstructor?.canManageLedger); const debouncedUserQuery = useDebouncedValue(userQuery, 240); const debouncedCourseQuery = useDebouncedValue(courseQuery, 240); const courseSearch = useAsyncData( async () => { if (!enabled) return { data: [] as CourseOpt[] }; if (!courseDropdownOpen) return { data: [] as CourseOpt[] }; const q = debouncedCourseQuery.trim(); const params = new URLSearchParams({ per_page: '15', page: '1', status: 'any', _fields: 'id,title', }); if (q) params.set('search', q); const r = await getWpApi().getWithTotal>( `/sik_course?${params.toString()}` ); const raw = Array.isArray(r.data) ? r.data : []; const out: CourseOpt[] = raw.map((p) => { const titleRendered = typeof p.title === 'object' && p.title && 'rendered' in p.title ? String(p.title.rendered || '') : String(p.title || ''); return { id: p.id, title: titleRendered.replace(/<[^>]+>/g, '').trim() || `Course #${p.id}` }; }); return { data: out }; }, [enabled, debouncedCourseQuery, courseDropdownOpen] ); const userSearch = useAsyncData( async () => { if (!enabled) return { data: [] as UserOpt[] }; if (!userDropdownOpen) return { data: [] as UserOpt[] }; const q = debouncedUserQuery.trim(); const base = new URLSearchParams({ per_page: '12', page: '1', context: 'edit', orderby: 'name', order: 'asc', }); if (q) base.set('search', q); base.set('roles', STAFF_SEARCH_ROLES.join(',')); try { const list = await getWpApi().get>(`/users?${base.toString()}`); const arr = Array.isArray(list) ? list : []; const merged: UserOpt[] = arr .filter((u): u is { id: number; name: string; email?: string } => Boolean(u && typeof u.id === 'number' && u.id > 0)) .map((u) => ({ id: u.id, name: u.name || `User #${u.id}`, email: typeof u.email === 'string' && u.email ? u.email : undefined, })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); return { data: merged.slice(0, 28) }; } catch { return { data: [] as UserOpt[] }; } }, [enabled, debouncedUserQuery, userDropdownOpen] ); const pickedUserLabel = useMemo(() => { const uid = parseInt(newUserId, 10); if (!Number.isFinite(uid) || uid <= 0) return null; const hit = (userSearch.data?.data || []).find((u) => u.id === uid); if (hit) { return hit.email ? `${hit.name} · ${hit.email}` : hit.name; } return `User #${uid}`; }, [newUserId, userSearch.data?.data]); const ledgerUserSearch = useAsyncData( async () => { if (!enabled) return { data: [] as UserOpt[] }; if (!ledgerDropdownOpen) return { data: [] as UserOpt[] }; const q = debouncedLedgerQuery.trim(); const base = new URLSearchParams({ per_page: '15', page: '1', context: 'edit', orderby: 'name', order: 'asc', }); if (q) base.set('search', q); base.set('roles', STAFF_SEARCH_ROLES.join(',')); try { const list = await getWpApi().get>(`/users?${base.toString()}`); const arr = Array.isArray(list) ? list : []; const merged: UserOpt[] = arr .filter((u): u is { id: number; name: string; email?: string } => Boolean(u && typeof u.id === 'number' && u.id > 0)) .map((u) => ({ id: u.id, name: u.name || `User #${u.id}`, email: typeof u.email === 'string' && u.email ? u.email : undefined, })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); return { data: merged.slice(0, 32) }; } catch { return { data: [] as UserOpt[] }; } }, [enabled, debouncedLedgerQuery, ledgerDropdownOpen] ); useEffect(() => { if (!ledgerDropdownOpen) return; const onDoc = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (!t.closest('[data-ledger-instructor-picker="1"]')) { setLedgerDropdownOpen(false); } }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [ledgerDropdownOpen]); useEffect(() => { if (!userDropdownOpen) return; const onDoc = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (!t.closest('[data-instructor-picker="1"]')) { setUserDropdownOpen(false); } }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [userDropdownOpen]); useEffect(() => { if (!courseDropdownOpen) return; const onDoc = (e: MouseEvent) => { const t = e.target as HTMLElement | null; if (!t) return; if (!t.closest('[data-course-picker="1"]')) { setCourseDropdownOpen(false); } }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [courseDropdownOpen]); const earningsLoader = useCallback(async () => { if (!enabled) return { ok: true, rows: [] as EarningsRow[], total: 0 }; const uid = parseInt(earningsUserId, 10); if (!Number.isFinite(uid) || uid <= 0) return { ok: true, rows: [] as EarningsRow[], total: 0 }; return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.multiInstructorEarnings(uid)); }, [earningsUserId, enabled]); const { loading: earningsLoading, data: earningsData, error: earningsError, refetch: refetchEarnings, } = useAsyncData(earningsLoader, [earningsUserId, enabled]); const loader = useCallback(async () => { if (!enabled) { return { ok: true, instructors: [] as InstructorRow[], share_total: 0, warnings: [] as string[] }; } const cid = parseInt(courseId, 10); if (!Number.isFinite(cid) || cid <= 0) { const r = await getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.multiInstructorCourseStaffAll({ per_page: 750, page: 1 })); return { ok: true, instructors: r?.rows || [], share_total: 0, warnings: [] }; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.multiInstructorCourseStaff(cid)); }, [courseId, enabled]); const { loading, data, error, refetch } = useAsyncData(loader, [courseId, enabled]); const rowsBase = data?.instructors ?? []; const shareTotal = data?.share_total ?? 0; const warnings = data?.warnings ?? []; const isGlobal = !courseId || parseInt(courseId, 10) <= 0; const rows = useMemo(() => { const q = debouncedStaffFilter.trim().toLowerCase(); if (!q) return rowsBase; return rowsBase.filter((r) => { const name = String(r.display_name || '').toLowerCase(); const email = String(r.user_email || '').toLowerCase(); const courseTitle = String(r.course_title || '').toLowerCase(); return name.includes(q) || email.includes(q) || courseTitle.includes(q); }); }, [rowsBase, debouncedStaffFilter]); useEffect(() => { const nextShare: Record = {}; const nextRole: Record = {}; for (const r of rowsBase) { nextShare[r.user_id] = String(Number(r.revenue_share).toFixed(2)); nextRole[r.user_id] = r.role === 'lead' ? 'lead' : 'co_instructor'; } setRowShareDraft(nextShare); setRowRoleDraft(nextRole); }, [rowsBase]); const loadCourseAuthor = useCallback(async (cid: number) => { if (!enabled || cid <= 0) { setCourseAuthorId(null); return; } try { const p = await getWpApi().get<{ author?: number }>( `/sik_course/${encodeURIComponent(String(cid))}?context=edit&_fields=author` ); const a = typeof p?.author === 'number' ? p.author : null; setCourseAuthorId(a && a > 0 ? a : null); } catch { setCourseAuthorId(null); } }, [enabled]); useEffect(() => { const cid = parseInt(courseId, 10); if (!Number.isFinite(cid) || cid <= 0) { setCourseAuthorId(null); return; } void loadCourseAuthor(cid); }, [courseId, loadCourseAuthor]); /** When course is chosen (including deep link `?course_id=`), load its title into the search field. */ useEffect(() => { const cid = parseInt(courseId, 10); if (!enabled || !Number.isFinite(cid) || cid <= 0) { return; } let cancelled = false; void (async () => { try { const p = await getWpApi().get<{ id?: number; title?: { rendered?: string } | string }>( `/sik_course/${encodeURIComponent(String(cid))}?_fields=id,title` ); if (cancelled || !p) return; const titleRendered = typeof p.title === 'object' && p.title && 'rendered' in p.title ? String(p.title.rendered || '') : String((p as { title?: string }).title || ''); const t = titleRendered.replace(/<[^>]+>/g, '').trim() || `Course #${cid}`; setCourseQuery(t); } catch { /* keep typed query */ } })(); return () => { cancelled = true; }; }, [courseId, enabled]); const addInstructor = async (e: FormEvent) => { e.preventDefault(); setMsg(null); const cid = parseInt(courseId, 10); const uid = parseInt(newUserId, 10); if (!Number.isFinite(cid) || cid <= 0 || !Number.isFinite(uid) || uid <= 0) { setMsg(__('Pick a course and an instructor.', 'sikshya')); return; } setSaving(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.multiInstructorCourseStaffWrite, { course_id: cid, user_id: uid, revenue_share: parseFloat(share) || 0, role: newMemberRole, }); setMsg(__('Instructor saved.', 'sikshya')); setNewUserId(''); setUserQuery(''); setNewMemberRole('co_instructor'); refetch(); } catch (err) { setMsg(err instanceof Error ? err.message : 'Request failed'); } finally { setSaving(false); } }; const saveStaffRow = async (userId: number) => { const cid = parseInt(courseId, 10); if (!Number.isFinite(cid) || cid <= 0) return; const raw = rowShareDraft[userId] ?? '0'; const revenueShare = parseFloat(raw); if (!Number.isFinite(revenueShare)) { setMsg(__('Enter a valid percentage.', 'sikshya')); return; } const roleRaw = rowRoleDraft[userId] ?? 'co_instructor'; const role = roleRaw === 'lead' ? 'lead' : 'co_instructor'; setSaving(true); setMsg(null); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.multiInstructorCourseStaffWrite, { course_id: cid, user_id: userId, revenue_share: revenueShare, role, }); setMsg(__('Staff row updated.', 'sikshya')); refetch(); } catch (err) { setMsg(err instanceof Error ? err.message : 'Request failed'); } finally { setSaving(false); } }; const setLedgerStatus = async (rowId: number, status: 'paid' | 'pending') => { setLedgerBusyId(rowId); setMsg(null); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.multiInstructorEarningsSetStatus, { id: rowId, status }); setMsg(status === 'paid' ? __('Marked as paid.', 'sikshya') : __('Marked as pending.', 'sikshya')); await refetchEarnings(); } catch (err) { setMsg(err instanceof Error ? err.message : 'Request failed'); } finally { setLedgerBusyId(null); } }; const deleteMember = async (userId: number) => { const cid = parseInt(courseId, 10); if (!Number.isFinite(cid) || cid <= 0) return; const ok = await dialog.confirm({ title: __('Remove staff member?', 'sikshya'), message: __('Remove this person from the course staff list?', 'sikshya'), confirmLabel: __('Remove', 'sikshya'), variant: 'danger', }); if (!ok) return; setSaving(true); setMsg(null); try { const path = `${SIKSHYA_ENDPOINTS.pro.multiInstructorCourseStaffWrite}?course_id=${encodeURIComponent(String(cid))}&user_id=${encodeURIComponent(String(userId))}`; await getSikshyaApi().delete(path); setMsg(__('Removed.', 'sikshya')); refetch(); } catch (err) { setMsg(err instanceof Error ? err.message : 'Request failed'); } finally { setSaving(false); } }; const pickedCourseSummary = useMemo(() => { const cid = parseInt(courseId, 10); if (!Number.isFinite(cid) || cid <= 0) return null; const hit = (courseSearch.data?.data || []).find((c) => c.id === cid); const title = hit?.title || (courseQuery.trim() ? courseQuery.trim() : null) || `Course #${cid}`; return { cid, title }; }, [courseId, courseSearch.data?.data, courseQuery]); return ( addon.enable()} addonError={addon.error} > <> {error ? refetch()} /> : null} {warnings.length > 0 ? (

{__('Heads up', 'sikshya')}

    {warnings.map((w) => (
  • {w}
  • ))}
) : null}

{__('Build course team', 'sikshya')}

Pick a course, search staff by name (Administrator, Editor, Author, or Sikshya instructor), set role and revenue weight. Weights apply to paid line items and are normalized per sale.

{pickedCourseSummary ? (
{pickedCourseSummary.title} #{pickedCourseSummary.cid} Open in builder
) : null}
setCourseQuery(e.target.value)} onFocus={() => setCourseDropdownOpen(true)} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm shadow-inner dark:border-slate-600 dark:bg-slate-950" placeholder={__('Type to search your catalog…', 'sikshya')} aria-label={__('Search courses', 'sikshya')} autoComplete="off" /> {courseSearch.loading ? (
{__('Searching courses…', 'sikshya')}
) : courseSearch.error ? (
{__('Could not search courses.', 'sikshya')}
) : courseDropdownOpen ? (
{(courseSearch.data?.data || []).length ? ( (courseSearch.data?.data || []).map((c) => ( )) ) : (
{debouncedCourseQuery.trim() ? __('No courses match.', 'sikshya') : __('Start typing to search.', 'sikshya')}
)}
) : null}
setUserQuery(e.target.value)} onFocus={() => setUserDropdownOpen(true)} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm shadow-inner dark:border-slate-600 dark:bg-slate-950" placeholder={__('Search by display name…', 'sikshya')} autoComplete="off" /> {pickedUserLabel ? (
{pickedUserLabel}
) : null} {userSearch.loading ? (
{__('Searching people…', 'sikshya')}
) : userSearch.error ? (
{__('Could not search users.', 'sikshya')}
) : userDropdownOpen ? (
{(userSearch.data?.data || []).length ? ( (userSearch.data?.data || []).map((u) => ( )) ) : (
{debouncedUserQuery.trim() ? __('No people match.', 'sikshya') : __('Type to search staff accounts.', 'sikshya')}
)}
) : null}
{saving ? __('Saving…', 'sikshya') : __('Add to team', 'sikshya')}
{msg ?

{msg}

: null}
{loading ? (
{__('Loading team…', 'sikshya')}
) : rowsBase.length === 0 ? ( ) : (
setStaffFilter(e.target.value)} placeholder={__('Search by name, email, or course…', 'sikshya')} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner dark:border-slate-600 dark:bg-slate-950" />
{isGlobal ? ( Showing {rows.length} staff row {rows.length === 1 ? '' : 's'} ) : ( Total weight (before normalization at checkout):{' '} {Number(shareTotal).toFixed(2)}% )}
{isGlobal ? : null} {rows.map((r) => { const isAuthor = courseAuthorId !== null && r.user_id === courseAuthorId; return ( {isGlobal ? ( ) : null} ); })}
{__('Course', 'sikshya')}{__('Instructor', 'sikshya')} {__('Role', 'sikshya')} Weight % {__('Actions', 'sikshya')}
{r.course_title || `Course #${r.course_id}`}
#{r.course_id}
{r.avatar_url ? ( ) : null}
{r.display_name || `User #${r.user_id}`} {isAuthor ? ( ) : null}
{r.user_email ? (
{r.user_email}
) : null}
{isGlobal ? ( ) : ( )}
{isGlobal ? ( {Number(r.revenue_share || 0).toFixed(2)}% ) : ( <> setRowShareDraft((prev) => ({ ...prev, [r.user_id]: e.target.value })) } aria-label={`Revenue weight for user ${r.user_id}`} /> )}
{isGlobal ? ( ) : ( )}
)}

{__('Instructor earnings ledger', 'sikshya')}

Ledger rows are created when a paid order completes. Search an instructor by name, then load their history. Admins with payout permissions can mark rows paid.

{ setLedgerUserQuery(e.target.value); setLedgerPickedLabel(''); }} onFocus={() => setLedgerDropdownOpen(true)} placeholder={__('Search by name…', 'sikshya')} className="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm dark:border-slate-600 dark:bg-slate-950" autoComplete="off" /> {earningsUserId && ledgerPickedLabel ? (
Viewing: {ledgerPickedLabel} #{earningsUserId}
) : null} {ledgerUserSearch.loading ? (
{__('Searching…', 'sikshya')}
) : ledgerUserSearch.error ? (
{__('Could not search users.', 'sikshya')}
) : ledgerDropdownOpen ? (
{(ledgerUserSearch.data?.data || []).length ? ( (ledgerUserSearch.data?.data || []).map((u) => ( )) ) : (
{debouncedLedgerQuery.trim() ? __('No matches.', 'sikshya') : __('Type to search instructors.', 'sikshya')}
)}
) : null}
refetchEarnings()}> {earningsLoading ? __('Loading…', 'sikshya') : __('Refresh ledger', 'sikshya')}
Total: {Number(earningsData?.total || 0).toFixed(2)}
{earningsError ? ( refetchEarnings()} /> ) : null} {(earningsData?.rows?.length || 0) > 0 ? (
{canManageLedger ? : null} {(earningsData?.rows || []).map((r) => ( {canManageLedger ? ( ) : null} ))}
{__('ID', 'sikshya')} {__('Order item', 'sikshya')} {__('Amount', 'sikshya')} {__('Status', 'sikshya')} {__('Created', 'sikshya')}{__('Admin', 'sikshya')}
{r.id} {r.order_item_id} {Number(r.amount).toFixed(2)} {r.status} {r.created_at || '—'} {r.status === 'pending' ? ( ) : r.status === 'paid' ? ( ) : ( )}
) : (

{__('No earnings rows for this query yet.', 'sikshya')}

)}
); }