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 ? (
{paragraphs.map((p, i) => (
{p}
))}
) : 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 applyBundle(bundle.id)}
className="group rounded-xl border border-slate-200 bg-slate-50/80 p-3 text-left transition hover:border-brand-300 hover:bg-brand-50/60 disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800/60 dark:hover:border-brand-700 dark:hover:bg-slate-800"
>
{bundle.label}
{bundle.tagline}
{bundle.addonIds.length} {__('add-ons', 'sikshya')}
))}
{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}
toggle(a)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
a.enabled
? 'bg-brand-600'
: 'bg-slate-200 dark:bg-slate-700'
} ${busy || locked ? 'opacity-60' : ''}`}
aria-label={`${a.enabled ? __('Disable', 'sikshya') : __('Enable', 'sikshya')} ${a.label}`}
>
{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}
);
})}
)}
);
}