import { useEffect, useMemo, useRef, useState } from 'react'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { NavIcon } from '../components/NavIcon'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { AsyncBoundary } from '../components/shared/AsyncBoundary'; import { SkeletonCard } from '../components/shared/Skeleton'; import { useAsyncData } from '../hooks/useAsyncData'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonPrimary } from '../components/shared/buttons'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAdminRouting } from '../lib/adminRouting'; import type { SikshyaReactConfig } from '../types'; import type { SettingsField, SettingsSection } from '../types/settingsSchema'; import { CourseSettingsTab } from '../components/CourseSettingsTab'; import { EnrollmentSettingsTab } from '../components/EnrollmentSettingsTab'; import { isTruthyCheckboxValue, renderSettingsField } from './settingsRenderField'; import { normalizeTabSections } from './settingsTabUtils'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { __ } from '../lib/i18n'; type SettingsSchema = Record; type SettingsTabMeta = { id: string; label: string; description: string; icon: string }; function fieldIsVisible(f: SettingsField, values: Record): boolean { const rules = (f as any).depends_all as Array<{ on: string; value?: string }> | undefined; if (Array.isArray(rules) && rules.length > 0) { for (const rule of rules) { const cur = values[rule.on]; if (rule.value !== undefined) { if (String(cur ?? '') !== String(rule.value)) { return false; } } else if (!cur || cur === '0' || cur === false) { return false; } } return true; } const dependsOn = (f as any).depends_on as string | undefined; if (dependsOn) { const p = values[dependsOn]; const dependsIn = (f as any).depends_in as string[] | undefined; if (Array.isArray(dependsIn) && dependsIn.length > 0) { const cur = String(p ?? ''); return dependsIn.some((v) => String(v) === cur); } const dependsValue = (f as any).depends_value as string | undefined; if (dependsValue !== undefined) { return String(p ?? '') === String(dependsValue); } return Boolean(p); } return true; } type PaymentGatewayMeta = { id: string; label: string; description: string; tier: string; locked: boolean; enabled_setting_key: string; setting_keys: string[]; icon_url?: string; }; type SettingsSchemaMeta = { payment_gateways?: PaymentGatewayMeta[]; }; function PaymentSettingsTab(props: { tabSchema: SettingsSection[]; schemaMeta: SettingsSchemaMeta; draft: Record; setDraft: React.Dispatch>>; renderField: (f: SettingsField) => React.ReactNode; }) { const { tabSchema, schemaMeta, draft, setDraft, renderField } = props; const gateways = ( Array.isArray(schemaMeta.payment_gateways) ? schemaMeta.payment_gateways : [] ) as PaymentGatewayMeta[]; const [open, setOpen] = useState(gateways[0]?.id || 'offline'); const byTitle = (t: string) => tabSchema.find((s) => (s.title || '').toLowerCase().trim() === t.toLowerCase().trim()); /** Prefer stable `section_key` from PHP so translated titles do not break the gateway manager. */ const secGateways = tabSchema.find((s) => (s.section_key || '') === 'payment_gateways') || byTitle('Payment Gateways'); const gatewayFields = Array.isArray(secGateways?.fields) ? secGateways!.fields! : []; // Pull key global fields into the gateway manager header. const getField = (key: string) => gatewayFields.find((f) => f.key === key); const fPrimary = getField('payment_gateway'); const fTestMode = getField('enable_test_mode'); const fOrder = getField('payment_gateways_order'); const fieldsByKey = useMemo(() => { const map = new Map(); for (const sec of tabSchema) { for (const f of sec.fields || []) { if (f?.key) map.set(f.key, f); } } return map; }, [tabSchema]); const orderedGateways = useMemo(() => { const current = parseGatewayOrder(draft['payment_gateways_order']); const byId = new Map(gateways.map((g) => [g.id, g])); const out: PaymentGatewayMeta[] = []; for (const id of current) { const g = byId.get(id); if (g) out.push(g); } for (const g of gateways) { if (!out.find((x) => x.id === g.id)) out.push(g); } return out; }, [gateways, draft]); const setGatewayOrder = (ids: string[]) => { setDraft((p) => ({ ...p, payment_gateways_order: serializeGatewayOrder(ids) })); }; const otherSections = tabSchema.filter( (s) => s !== secGateways && Array.isArray(s.fields) && s.fields.length ); const GatewayRow = ({ id, title, subtitle, badge, enabledKey, locked, canReorder, onMoveUp, onMoveDown, }: { id: string; title: string; subtitle: string; badge?: 'ACTIVE' | 'TEST' | 'PRO'; enabledKey?: string; locked?: boolean; canReorder?: boolean; onMoveUp?: () => void; onMoveDown?: () => void; }) => { const selected = open === id; const enabled = enabledKey ? isTruthyCheckboxValue(draft[enabledKey]) : true; return ( ) : null} {enabledKey ? ( ) : null} ); }; const gatewaySettingsFields = (g: PaymentGatewayMeta): SettingsField[] => { const out: SettingsField[] = []; for (const k of g.setting_keys || []) { const f = fieldsByKey.get(k); if (f) out.push(f); } return out; }; return (
{fPrimary ? renderField(fPrimary) : null}
{fTestMode ? renderField(fTestMode) : null}
{/* Keep this field present for persistence even if users don’t touch it directly. */}
{fOrder ? renderField(fOrder) : null}
{orderedGateways.map((g, idx) => { const enabledKey = g.enabled_setting_key || undefined; const locked = !!g.locked; const badge = locked ? ('PRO' as const) : enabledKey && isTruthyCheckboxValue(draft[enabledKey]) ? ('ACTIVE' as const) : undefined; const fields = gatewaySettingsFields(g); const ids = orderedGateways.map((x) => x.id); const canReorder = orderedGateways.length > 1; const onMoveUp = idx > 0 ? () => { const next = [...ids]; const [item] = next.splice(idx, 1); next.splice(idx - 1, 0, item); setGatewayOrder(next); } : undefined; const onMoveDown = idx < orderedGateways.length - 1 ? () => { const next = [...ids]; const [item] = next.splice(idx, 1); next.splice(idx + 1, 0, item); setGatewayOrder(next); } : undefined; return (
{open === g.id ? (
{locked ? (
This gateway is available in the Pro version.
) : null} {fields.length ? (
{fields.filter((f) => fieldIsVisible(f, draft)).map(renderField)}
) : (
{locked ? __('Upgrade to configure this gateway.', 'sikshya') : __('No additional settings for this gateway.', 'sikshya')}
)}
) : null}
); })}
{otherSections.map((sec, i) => (
{(sec.fields || []).filter((f) => fieldIsVisible(f, draft)).map((f) => renderField(f))}
))}
); } /** Sidebar order: store → join/pay → content types → people → polish → system. */ const TAB_META: SettingsTabMeta[] = [ { id: 'general', label: 'General', description: 'Core plugin behavior and defaults.', icon: 'cog' }, { id: 'courses', label: 'Courses', description: 'Catalog, discovery, and course page defaults.', icon: 'course', }, { id: 'lessons', label: 'Lessons', description: 'Lesson types, player, and previews.', icon: 'bookOpen' }, { id: 'quizzes', label: 'Quizzes', description: 'Scoring, timing, and behavior.', icon: 'questionMarkCircle' }, { id: 'assignments', label: 'Assignments', description: 'Submission and grading defaults.', icon: 'clipboard' }, { id: 'progress', label: 'Progress', description: 'Tracking rules and completion logic.', icon: 'chartPresentation' }, { id: 'certificates', label: 'Certificates', description: 'Issuance rules and verification defaults.', icon: 'documentText', }, { id: 'enrollment', label: 'Enrollment', description: 'Enrollment rules, checkout buttons, limits, and policies.', icon: 'userPlus', }, { id: 'payment', label: 'Payment', description: 'Gateways, currency, and checkout.', icon: 'creditCard' }, { id: 'students', label: 'Students', description: 'Learner experience and access.', icon: 'users' }, { id: 'instructors', label: 'Instructors', description: 'Instructor permissions and workflow.', icon: 'userCircle' }, { id: 'notifications', label: 'Notifications', description: 'In-app notices, alerts, and nudges.', icon: 'bell', }, { id: 'integrations', label: 'Marketing tags', description: 'Analytics and marketing pixel IDs.', icon: 'tag', }, { id: 'permalinks', label: 'Permalinks', description: 'Cart, checkout, account, and content URLs.', icon: 'link' }, { id: 'security', label: 'Security', description: 'Roles, access rules, and data safety.', icon: 'lockClosed' }, { id: 'advanced', label: 'Advanced', description: 'Developer and system options.', icon: 'wrench' }, ]; function normalizeForDirtyCompare(v: unknown): string { if (v === null || v === undefined) return ''; if (typeof v === 'boolean') return v ? '1' : '0'; if (typeof v === 'number') return String(v); return String(v); } function stableNormalizeRecord(obj: Record): Record { const out: Record = {}; const keys = Object.keys(obj).sort(); for (const k of keys) { out[k] = normalizeForDirtyCompare(obj[k]); } return out; } function parseGatewayOrder(v: unknown): string[] { const s = typeof v === 'string' ? v.trim() : ''; if (!s) return []; return s .split(',') .map((x) => x.trim()) .filter(Boolean); } function serializeGatewayOrder(ids: string[]): string { return ids.map((x) => x.trim()).filter(Boolean).join(','); } function sectionIconName(raw?: string): string { // Settings schema icons come from PHP as FontAwesome classes (e.g. "fas fa-link"). // React admin uses our own SVG icon set, so map common FA names to our icon keys. const s = (raw || '').trim(); const fa = s.replace(/^fas\s+fa-/, '').replace(/^fa-/, ''); switch (fa) { case 'link': return 'link'; case 'folder-open': case 'folder': return 'course'; case 'tags': return 'tag'; case 'route': return 'link'; case 'credit-card': return 'creditCard'; case 'shopping-cart': return 'shoppingCart'; case 'cog': case 'cogs': return 'cog'; case 'info-circle': return 'helpCircle'; case 'question-circle': return 'helpCircle'; case 'bell': return 'bell'; case 'shield-alt': return 'lockClosed'; case 'tools': return 'wrench'; case 'eye': return 'bookOpen'; default: return fa || 'cog'; } } function SectionCard({ children, title, description, icon, locked, lockedReason, onUpgrade, }: { children: React.ReactNode; title?: string; description?: string; icon?: string; locked?: boolean; lockedReason?: string; onUpgrade?: () => void; }) { return (
{title ? (

{title}

{locked ? ( Pro ) : null}
{description ? (

{description}

) : null} {locked ? (

{lockedReason || 'Turn on the matching addon to edit these settings.'} {onUpgrade ? ( <> {' '} ) : null}

) : null}
) : null} {children}
); } export function SettingsPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const { confirm } = useSikshyaDialog(); const { navigateView } = useAdminRouting(); const qTab = config.query?.tab; /** Email moved to its own admin screen; avoid flashing the wrong tab. */ const initialTab = qTab === 'email' ? 'general' : qTab && qTab.length ? qTab : 'general'; const [tab, setTab] = useState(initialTab); useEffect(() => { if (qTab === 'email') { navigateView('email', {}, { replace: true }); } }, [qTab, navigateView]); const schema = useAsyncData(async () => { const res = await getSikshyaApi().get<{ success: boolean; data?: { tabs?: SettingsSchema; meta?: SettingsSchemaMeta } }>( SIKSHYA_ENDPOINTS.settings.schema ); if (!res.success) { throw new Error(__('Could not load settings schema.', 'sikshya')); } return { tabs: res.data?.tabs || {}, meta: res.data?.meta || {} }; }, []); // Avoid flicker on tab switches by caching per-tab values and rendering cached values // while the next tab loads in the background. const valuesCacheRef = useRef>>({}); const [valuesLoading, setValuesLoading] = useState(false); const [valuesError, setValuesError] = useState(null); const cachedValuesForTab = valuesCacheRef.current[tab] ?? null; useEffect(() => { let cancelled = false; setValuesLoading(true); setValuesError(null); (async () => { try { const res = await getSikshyaApi().get<{ success: boolean; data?: { values?: Record } }>( SIKSHYA_ENDPOINTS.settings.values(tab) ); if (!res.success) { throw new Error(__('Could not load settings values.', 'sikshya')); } const next = res.data?.values || {}; if (cancelled) return; valuesCacheRef.current = { ...valuesCacheRef.current, [tab]: next }; // If this tab is still active, hydrate draft from the fresh values. setDraft(next); setInitialValues(next); setSaveError(null); toast.clear(); } catch (e) { if (!cancelled) { setValuesError(e); } } finally { if (!cancelled) { setValuesLoading(false); } } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab]); const [draft, setDraft] = useState>({}); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const toast = useTopRightToast(3800); const [initialValues, setInitialValues] = useState>({}); // On first render, use cached values immediately (no skeleton), then background refresh updates it. useEffect(() => { if (!cachedValuesForTab) return; setDraft(cachedValuesForTab); setInitialValues(cachedValuesForTab); }, [tab]); // intentional: only on tab switches useEffect(() => { // Keep URL in sync for shareable / refresh-safe navigation. const url = new URL(window.location.href); url.searchParams.set('tab', tab); window.history.replaceState({}, '', url.toString()); }, [tab]); const tabMeta = useMemo(() => TAB_META.find((t) => t.id === tab) || TAB_META[0], [tab]); const tabSchema = normalizeTabSections((schema.data?.tabs || {})[tab]); const schemaMeta = schema.data?.meta || {}; const dirty = useMemo(() => { try { return JSON.stringify(stableNormalizeRecord(draft)) !== JSON.stringify(stableNormalizeRecord(initialValues)); } catch { return true; } }, [draft, initialValues]); const onSave = async () => { setSaving(true); setSaveError(null); toast.clear(); try { const res = await getSikshyaApi().post<{ success: boolean; message?: string; data?: { values?: Record } }>( SIKSHYA_ENDPOINTS.settings.save, { tab, values: draft } ); if (!res.success) { throw new Error(res.message || 'Save failed.'); } const next = res.data?.values || {}; setDraft(next); setInitialValues(next); const msg = res.message || 'Settings saved.'; toast.success(__('Saved', 'sikshya'), msg); } catch (e) { setSaveError(e); toast.error(__('Save failed', 'sikshya'), e instanceof Error ? e.message : 'Could not save settings. Please try again.'); } finally { setSaving(false); } }; const onReset = async () => { const ok = await confirm({ title: __('Reset settings?', 'sikshya'), message: `Reset ${tabMeta.label} settings to their default values?`, variant: 'danger', confirmLabel: __('Reset', 'sikshya'), }); if (!ok) { return; } setSaving(true); setSaveError(null); toast.clear(); try { const res = await getSikshyaApi().post<{ success: boolean; message?: string }>(SIKSHYA_ENDPOINTS.settings.reset, { tab, }); if (!res.success) { throw new Error(res.message || 'Reset failed.'); } // Force-refresh the active tab after reset. delete valuesCacheRef.current[tab]; setValuesLoading(false); setValuesError(null); // Trigger reload by cycling tab to itself (effect runs on dependency change only). // We do a direct fetch here to avoid UI flicker. const refreshed = await getSikshyaApi().get<{ success: boolean; data?: { values?: Record } }>( SIKSHYA_ENDPOINTS.settings.values(tab) ); if (refreshed.success) { const next = refreshed.data?.values || {}; valuesCacheRef.current = { ...valuesCacheRef.current, [tab]: next }; setDraft(next); setInitialValues(next); } const msg = res.message || 'Settings reset.'; toast.success(__('Reset', 'sikshya'), msg); } catch (e) { setSaveError(e); toast.error(__('Reset failed', 'sikshya'), e instanceof Error ? e.message : 'Could not reset settings. Please try again.'); } finally { setSaving(false); } }; const renderField = (f: SettingsField) => renderSettingsField(draft, setDraft, f); const onSendUsageNow = async () => { setSaving(true); setSaveError(null); toast.clear(); try { const res = await getSikshyaApi().post<{ success: boolean; message?: string; data?: { last_sync?: number; last_error?: unknown }; }>(SIKSHYA_ENDPOINTS.admin.usageTrackingSendNow); if (!res.success) { throw new Error(res.message || 'Send failed.'); } toast.success(__('Sent', 'sikshya'), res.message || 'Usage data sent.'); } catch (e) { toast.error(__('Send failed', 'sikshya'), e instanceof Error ? e.message : 'Could not send usage data.'); } finally { setSaving(false); } }; const showShellSkeleton = schema.loading || (valuesLoading && !cachedValuesForTab); const effectiveError = schema.error || valuesError; return (
{ schema.refetch(); // Retry the active tab fetch by forcing the shell to show skeleton, then reselecting the tab. delete valuesCacheRef.current[tab]; setValuesError(null); setValuesLoading(true); setTab((t) => t); }} skeleton={
} >
{tabMeta.label}

{tabMeta.label} settings

void onSave()}> {saving ? __('Saving…', 'sikshya') : __('Save changes', 'sikshya')}
{saveError ? (
setSaveError(null)} />
) : null} {/* Add bottom padding so the sticky footer actions don't cover the last fields. */}
{tabSchema.length ? ( tab === 'payment' ? ( ) : tab === 'courses' ? ( ) : tab === 'enrollment' ? ( ) : (
{tabSchema.map((sec, i) => { const fields = Array.isArray(sec.fields) ? sec.fields : []; if (!fields.length) { return null; } const isPrivacyUsageSection = tab === 'advanced' && (String((sec as { section_key?: string }).section_key || '').toLowerCase().trim() === 'privacy_usage' || String(sec.title || '').toLowerCase().trim() === 'privacy & usage'); return ( navigateView('addons', {}) : undefined} >
{isPrivacyUsageSection ? fields.flatMap((f) => { const out: React.ReactNode[] = [renderField(f)]; if (f.key === 'allow_usage_tracking') { const enabled = isTruthyCheckboxValue(draft['allow_usage_tracking']); const collectUrl = 'https://sikshya.mantrabrain.com/docs/which-types-of-data-are-being-tracked/'; out.push(
No personal or learner details—only technical signals. {' '} Read the full list (opens in a new tab):{' '} What we collect
); out.push(
Send usage data now
Triggers an immediate one-time send to validate connectivity.
{!enabled ? (

Enable “Share anonymous usage data” to use Send now.

) : null}
); } return out; }) : fields.map(renderField)}
); })}
) ) : (

{__('No settings defined for this tab.', 'sikshya')}

Add fields in the SettingsManager config to show them here.

)}
{/* Sticky footer actions (duplicate of header buttons). */}
{dirty ? __('You have unsaved changes.', 'sikshya') : __('All changes saved.', 'sikshya')}
void onSave()}> {saving ? __('Saving…', 'sikshya') : __('Save changes', 'sikshya')}
); }