import { useEffect, useMemo, useState } from 'react'; import { useShellState } from '../context/ShellStateContext'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonSecondary } from '../components/shared/buttons'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { getErrorSummary, getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { appViewHref } from '../lib/appUrl'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import type { SikshyaReactConfig } from '../types'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { __, sprintf, t } from '../lib/i18n'; type AddonTier = 'free' | 'starter' | 'pro' | 'scale'; type AddonRow = { id: string; label: string; description: string; /** Longer help for hover/focus popover (plain language). */ detailDescription?: string; tier: AddonTier; group: string; dependencies: string[]; enabled: boolean; licenseOk: boolean; /** Deep link to the addon's documentation page (computed server-side). */ docsUrl?: string; }; type AddonsResponse = { success: boolean; addons: AddonRow[]; enabled: string[]; licensing?: { isProActive?: boolean; proPluginInstalled?: boolean; siteTier?: string; upgradeUrl?: string; siteTierLabel?: string; }; }; /** * Default list order (most → least important). Must match * {@see \Sikshya\Api\AdminAddonsRestRoutes::addonImportanceOrder}. */ const ADDON_IMPORTANCE_ORDER: readonly string[] = [ // Commerce essentials (most used on selling sites). 'subscriptions', 'coupons_advanced', // Email + automation (commonly enabled early). 'email_advanced_customization', 'email_marketing', 'webhooks', 'zapier', 'public_api_keys', // Learning experience + access rules (core LMS upgrades). 'prerequisites', 'content_drip', 'drip_notifications', 'community_discussions', // Teaching operations. 'multi_instructor', 'gradebook', 'certificates_advanced', // Assessment + assignments. 'quiz_advanced', 'assignments_advanced', // Reporting + audit. 'reports_advanced', 'activity_log', 'calendar', // Content formats / delivery. 'live_classes', 'scorm_h5p_pro', // Storefront / marketplace / packaging. 'course_bundles', 'marketplace_multivendor', // Identity + theming. 'social_login', 'white_label', // Niche / enterprise / scale. 'instructor_dashboard', 'enterprise_reports', 'multilingual_enterprise', 'multisite_scale', ]; function addonImportanceRank(id: string): number { const i = ADDON_IMPORTANCE_ORDER.indexOf(id); return i === -1 ? ADDON_IMPORTANCE_ORDER.length : i; } // NOTE: the local tierBadge() helper used to live here; it was replaced by // `` from shared/list/StatusBadge.tsx so a single // change updates every tier pill across the admin. function addonRequiresPlanLabel(tier: AddonTier): string { if (tier === 'scale') return 'Scale'; if (tier === 'pro') return 'Growth'; if (tier === 'starter') return 'Starter'; return 'Free'; } function addonLicenseLocked(a: AddonRow): boolean { return (a.tier === 'starter' || a.tier === 'pro' || a.tier === 'scale') && !a.licenseOk; } /** Human label for which commercial plan unlocks this catalog tier (matches PHP FeatureRegistry tiers). */ function tierPlanLabel(tier: AddonTier): string { if (tier === 'starter') return 'Starter'; if (tier === 'pro') return 'Growth'; if (tier === 'scale') return 'Scale'; return 'Free'; } /** * Card preview: PHP sends blocks separated by blank lines; showing that with `pre-line` created a large gap * between “what it is” and “when to enable”. We join blocks with spaces for a tight 3-line clamp. */ function addonCardPreviewText(raw: string): string { return raw .replace(/\r\n/g, '\n') .split(/\n\s*\n/) .map((block) => block.replace(/\s+/g, ' ').trim()) .filter(Boolean) .join(' '); } /** Hover the short description to read the full add-on guide (popover). */ function AddonDescriptionWithHelp(props: { addonId: string; label: string; description: string; detailDescription?: string }) { const panelId = `sikshya-addon-detail-${props.addonId}`; const longText = (props.detailDescription && props.detailDescription.trim()) || props.description; const paragraphs = longText .split(/\n\n/) .map((p) => p.trim()) .filter(Boolean); const preview = addonCardPreviewText(props.description); const hasPopover = paragraphs.length > 0; return (

{preview}

{hasPopover ? ( ) : null}
); } export function AddonsPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const { refreshShell } = useShellState(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); const [error, setError] = useState(null); const [q, setQ] = useState(''); const [groupFilter, setGroupFilter] = useState('all'); const [tierFilter, setTierFilter] = useState<'all' | AddonTier>('all'); const [sort, setSort] = useState<'importance' | 'name_asc' | 'name_desc' | 'tier' | 'status'>('importance'); const [selected, setSelected] = useState>({}); const toast = useTopRightToast(3200); const refetch = async () => { setLoading(true); setError(null); try { const res = await getSikshyaApi().get(SIKSHYA_ENDPOINTS.admin.addons); setData(res); setSelected((prev) => { // Drop selections for addons that no longer exist. const next: Record = {}; const list = Array.isArray(res.addons) ? res.addons : []; list.forEach((a) => { if (prev[a.id]) next[a.id] = true; }); return next; }); } catch (e) { setError(e); } finally { setLoading(false); } }; useEffect(() => { void refetch(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const allRows = useMemo(() => { const all = Array.isArray(data?.addons) ? data.addons : []; const needle = q.trim().toLowerCase(); return all.filter((a) => { if (tierFilter !== 'all' && a.tier !== tierFilter) return false; if (groupFilter !== 'all' && (a.group || 'general') !== groupFilter) return false; if (!needle) return true; const s = `${a.label} ${a.description} ${a.detailDescription ?? ''} ${a.group} ${a.tier} ${a.id}`.toLowerCase(); return s.includes(needle); }); }, [data, q, groupFilter, tierFilter]); const groups = useMemo(() => { const all = Array.isArray(data?.addons) ? data.addons : []; const set = new Set(); all.forEach((r) => set.add(r.group || 'general')); const list = Array.from(set).sort((a, b) => a.localeCompare(b)); return list; }, [data]); const rows = useMemo(() => { const list = [...allRows]; if (sort === 'importance') { list.sort((a, b) => addonImportanceRank(a.id) - addonImportanceRank(b.id) || a.label.localeCompare(b.label)); } else if (sort === 'name_desc') { list.sort((a, b) => b.label.localeCompare(a.label)); } else if (sort === 'tier') { const w = (t: AddonTier) => t === 'free' ? 0 : t === 'starter' ? 1 : t === 'pro' ? 2 : t === 'scale' ? 3 : 0; list.sort((a, b) => w(a.tier) - w(b.tier) || a.label.localeCompare(b.label)); } else if (sort === 'status') { // Enabled first, then locked, then name. const key = (a: AddonRow) => { const locked = addonLicenseLocked(a); return `${a.enabled ? '0' : '1'}-${locked ? '0' : '1'}-${a.label.toLowerCase()}`; }; list.sort((a, b) => key(a).localeCompare(key(b))); } else { list.sort((a, b) => a.label.localeCompare(b.label)); } return list; }, [allRows, sort]); const upgradeUrl = data?.licensing?.upgradeUrl || config.licensing?.upgradeUrl || 'https://mantrabrain.com/plugins/sikshya-lms/pricing/'; const licensing = data?.licensing; const isProActive = licensing?.isProActive === true; const proPluginInstalled = licensing?.proPluginInstalled === true; const siteTierLabel = licensing?.siteTierLabel || config.licensing?.siteTierLabel || 'Free'; const lockedCount = useMemo(() => { const list = Array.isArray(data?.addons) ? data.addons : []; return list.filter((a) => addonLicenseLocked(a)).length; }, [data?.addons]); const toggle = async (addon: AddonRow) => { setBusyId(addon.id); setError(null); toast.clear(); try { const path = addon.enabled ? SIKSHYA_ENDPOINTS.admin.addonsDisable(addon.id) : SIKSHYA_ENDPOINTS.admin.addonsEnable(addon.id); const res = await getSikshyaApi().post(path, {}); setData(res); await refreshShell(); if (addon.enabled) { toast.success(__('Disabled', 'sikshya'), `${addon.label} disabled.`); } else { toast.success(__('Enabled', 'sikshya'), `${addon.label} enabled.`); } } catch (e) { setError(e); toast.error(__('Action failed', 'sikshya'), getErrorSummary(e)); } finally { setBusyId(null); } }; const selectedIds = useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]); const allVisibleSelected = useMemo(() => { if (!rows.length) return false; return rows.every((r) => !!selected[r.id]); }, [rows, selected]); const setAllVisibleSelected = (next: boolean) => { setSelected((prev) => { const out = { ...prev }; rows.forEach((r) => { if (next) out[r.id] = true; else delete out[r.id]; }); return out; }); }; const bulkUpdate = async (mode: 'enable' | 'disable') => { const ids = selectedIds; if (!ids.length) return; setBulkBusy(true); setError(null); toast.clear(); let updated = 0; try { // Sequential to keep server load predictable and responses consistent. for (const id of ids) { const row = (Array.isArray(data?.addons) ? data.addons : []).find((a) => a.id === id); if (!row) continue; const locked = addonLicenseLocked(row); if (locked) continue; if (mode === 'enable' && row.enabled) continue; if (mode === 'disable' && !row.enabled) continue; const path = mode === 'enable' ? SIKSHYA_ENDPOINTS.admin.addonsEnable(id) : SIKSHYA_ENDPOINTS.admin.addonsDisable(id); const res = await getSikshyaApi().post(path, {}); setData(res); updated++; } setSelected({}); if (updated > 0) { await refreshShell(); toast.success(mode === 'enable' ? __('Enabled', 'sikshya') : __('Disabled', 'sikshya'), `${updated} add-on(s) updated.`); } else { toast.info('No changes', 'Nothing to update for the selected add-ons.'); } } catch (e) { setError(e); toast.error(__('Bulk update failed', 'sikshya'), getErrorSummary(e)); } finally { setBulkBusy(false); } }; // Recommended bundles — curated starter packs to jump-start common configurations. // Each addon ID must exist in the catalog; locked addons (license) are skipped at apply-time. const RECOMMENDED_BUNDLES: { id: string; label: string; tagline: string; addonIds: string[] }[] = [ { id: 'starter', label: __('Starter pack', 'sikshya'), tagline: __('Just selling courses', 'sikshya'), addonIds: ['coupons_advanced', 'email_advanced_customization', 'certificates_advanced'], }, { id: 'growth', label: __('Growth pack', 'sikshya'), tagline: __('Scaling a paid LMS business', 'sikshya'), addonIds: [ 'subscriptions', 'coupons_advanced', 'content_drip', 'prerequisites', 'gradebook', 'certificates_advanced', 'multi_instructor', 'email_marketing', ], }, { id: 'enterprise', label: __('Enterprise pack', 'sikshya'), tagline: __('Multi-site, reports, audit log, white-label', 'sikshya'), addonIds: [ 'enterprise_reports', 'multilingual_enterprise', 'multisite_scale', 'activity_log', 'white_label', 'webhooks', 'public_api_keys', ], }, ]; const applyBundle = async (bundleId: string) => { const bundle = RECOMMENDED_BUNDLES.find((b) => b.id === bundleId); if (!bundle) return; const allAddons: AddonRow[] = Array.isArray(data?.addons) ? data!.addons : []; setBulkBusy(true); setError(null); toast.clear(); let enabled = 0; let skipped = 0; try { for (const id of bundle.addonIds) { const row = allAddons.find((a) => a.id === id); if (!row || row.enabled) continue; if (addonLicenseLocked(row)) { skipped++; continue; } const res = await getSikshyaApi().post( SIKSHYA_ENDPOINTS.admin.addonsEnable(id), {} ); setData(res); enabled++; } if (enabled > 0) { await refreshShell(); toast.success( __('Bundle applied', 'sikshya'), skipped > 0 ? `${bundle.label}: ${enabled} enabled, ${skipped} need a higher plan.` : `${bundle.label}: ${enabled} add-on(s) enabled.` ); } else if (skipped > 0) { toast.info( __('Plan required', 'sikshya'), `${bundle.label} needs a higher Sikshya Pro plan to enable its add-ons.` ); } else { toast.info(__('Already on', 'sikshya'), `${bundle.label}: every add-on is already enabled.`); } } catch (e) { setError(e); toast.error(__('Bundle failed', 'sikshya'), getErrorSummary(e)); } finally { setBulkBusy(false); } }; return ( {error ? void refetch()} /> : null}

{__('How Sikshya Free, Pro, and add-ons fit together', 'sikshya')}

  • {sprintf( __('%1$s — courses, lessons, quizzes, enrollments, and basic checkout. Nothing on this page can “turn off” the core plugin.', 'sikshya'), 'Sikshya (free)' )}
  • {sprintf( __('%1$s — unlocks commercial plans (Starter, Growth, Scale). Install and activate the Pro plugin, then activate your license under %2$s.', 'sikshya'), 'Sikshya Pro (paid license)', __('License', 'sikshya') )}{' '} {__('License', 'sikshya')} .
  • {__('Add-ons (this page)', 'sikshya')} — optional modules (subscriptions, drip, gradebook, …). Enable one, then open its menu in the sidebar to configure it. Site-wide defaults often live under{' '} {__('Settings', 'sikshya')}; per-feature tabs may say {__('Add-on defaults', 'sikshya')}.

Tip: in filters and badges, {__('Growth', 'sikshya')} is the catalog name for the mid-tier Pro plan (technical tier {__('pro', 'sikshya')} ). {__('Scale', 'sikshya')} is the highest tier.

{data && !isProActive ? (

{__('Upgrade to Sikshya Pro', 'sikshya')}

Starter, Growth, and Scale add-ons on this page need an active Sikshya Pro license. After you purchase a plan, install and activate the Sikshya Pro plugin — then you can turn each add-on on here and use its admin screens and learner-facing features.

{!proPluginInstalled ? (

The Sikshya Pro plugin is not detected on this site yet. Use the download from your purchase email or account to install it.

) : null}
View plans & upgrade
) : null} {data && isProActive && lockedCount > 0 ? (
Your site is on {siteTierLabel}. Add-ons marked “Requires …” need a higher plan — use {__('Upgrade to unlock', 'sikshya')} on each card or visit the store to upgrade.
) : null}

{__('Recommended starter packs', 'sikshya')}

{__('Pick a pack that matches your goal — we’ll flip on the add-ons that map to it.', 'sikshya')}

{RECOMMENDED_BUNDLES.map((bundle) => ( ))}
void bulkUpdate('enable')} > {t('Enable selected')} void bulkUpdate('disable')} > {t('Disable selected')} {t('Showing')}{' '} {rows.length}{' '} {t('add-ons')}
setQ(e.target.value)} placeholder={t('Search add-ons…')} className="w-full max-w-[360px] rounded-xl border border-slate-200 bg-white px-3 py-2 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 dark:border-slate-600 dark:bg-slate-800 dark:text-white" />
{loading && !data ? (
{t('Loading add-ons…')}
) : (
{rows.map((a) => { const locked = addonLicenseLocked(a); const busy = busyId === a.id || bulkBusy; const isSelected = !!selected[a.id]; return (
setSelected((prev) => { const next = { ...prev }; if (e.target.checked) next[a.id] = true; else delete next[a.id]; return next; }) } className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-brand-600 focus:ring-2 focus:ring-brand-500/40 dark:border-slate-600 dark:bg-slate-800" aria-label={`Select ${a.label}`} />
{a.label}
{a.description ? ( ) : null}
{a.group || 'general'} {a.dependencies?.length ? ( Depends: {a.dependencies.length} ) : null} {locked ? ( Requires {addonRequiresPlanLabel(a.tier)} ) : null} {a.docsUrl ? ( {__('Docs', 'sikshya')} ) : null}
{locked ? (

{isProActive ? `Requires ${tierPlanLabel(a.tier)} plan or higher — upgrade your subscription to enable.` : 'Requires Sikshya Pro — purchase a plan, install the Pro plugin, then enable this add-on.'}

Upgrade to unlock
) : null}
); })}
)}
); }