import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { getErrorSummary, getSikshyaApi, getWpApi, SIKSHYA_ENDPOINTS } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { HorizontalEditorTabs } from '../components/shared/HorizontalEditorTabs'; import { DataTable, type Column } from '../components/shared/DataTable'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { BulkActionsBar } from '../components/shared/list/BulkActionsBar'; import { ButtonPrimary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { Modal } from '../components/shared/Modal'; import { RowActionsMenu } from '../components/shared/list/RowActionsMenu'; import { DataTableSkeleton } from '../components/shared/Skeleton'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { useAdminRouting } from '../lib/adminRouting'; import { appViewHref } from '../lib/appUrl'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig, WpRestUser } from '../types'; import { AddonSettingsPage } from './AddonSettingsPage'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { __ } from '../lib/i18n'; type Plan = { id: number; name: string; amount: number; currency: string; interval_unit: string; status: string; }; type SubRow = { id: number; user_id: number; plan_id: number; status: string; gateway: string; current_period_end: string | null; }; type TabId = 'plans' | 'list' | 'settings'; const TAB_IDS: TabId[] = ['plans', 'list', 'settings']; type ToastState = { kind: 'success' | 'error'; text: string } | null; /** * Tabbed workspace that separates plan definitions from the subscriptions * assigned to users. Keeping these on two tabs (instead of stacked lists) * matches the rest of our admin hubs (Sales, Certificates, People) and lets * each view own its own toolbar, empty state, and modal. */ export function SubscriptionsProPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const featureOk = isFeatureEnabled(config, 'subscriptions'); const addon = useAddonEnabled('subscriptions'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const toast = useTopRightToast(2600); const pushToast = (t: ToastState) => { if (!t) { toast.clear(); return; } if (t.kind === 'success') { toast.success(__('Success', 'sikshya'), t.text); return; } toast.error(__('Error', 'sikshya'), t.text); }; const { route, navigateView } = useAdminRouting(); const activeTab: TabId = (() => { const fromUrl = String(route.query?.tab || '').trim(); return (TAB_IDS as string[]).includes(fromUrl) ? (fromUrl as TabId) : 'list'; })(); const setActiveTab = (id: string) => { const view = typeof route.page === 'string' && route.page.trim() !== '' ? route.page.trim() : 'subscriptions'; navigateView(view, { tab: id }); }; const loader = useCallback(async () => { if (!enabled) { return { ok: true, subscriptions: [] as SubRow[], plans: [] as Plan[], users: [] as WpRestUser[], manualGrantAllowed: true, }; } const [subs, plans, settingsResp, paymentValues] = await Promise.all([ getSikshyaApi().get<{ ok?: boolean; subscriptions?: SubRow[] }>(SIKSHYA_ENDPOINTS.pro.subscriptions), getSikshyaApi().get<{ ok?: boolean; plans?: Plan[] }>(SIKSHYA_ENDPOINTS.pro.plans), getSikshyaApi() .get<{ ok?: boolean; options?: Record }>('/pro/addons/subscriptions/settings') .catch(() => ({ ok: true, options: {} as Record })), getSikshyaApi() .get<{ success?: boolean; data?: Record }>(SIKSHYA_ENDPOINTS.settings.values('payment')) .catch(() => ({ success: true, data: {} as Record })), ]); const rawSubs = Array.isArray(subs.subscriptions) ? subs.subscriptions : []; const rawPlans = Array.isArray(plans.plans) ? plans.plans : []; // Defensive: some responses serialize numbers as strings. const normPlans: Plan[] = rawPlans .map((p) => ({ id: Number((p as unknown as { id?: unknown }).id ?? 0), name: String((p as unknown as { name?: unknown }).name ?? ''), amount: Number((p as unknown as { amount?: unknown }).amount ?? 0), currency: String((p as unknown as { currency?: unknown }).currency ?? 'USD'), interval_unit: String((p as unknown as { interval_unit?: unknown }).interval_unit ?? 'month'), status: String((p as unknown as { status?: unknown }).status ?? 'active'), })) .filter((p) => p.id > 0); const normSubs: SubRow[] = rawSubs .map((r) => ({ id: Number((r as unknown as { id?: unknown }).id ?? 0), user_id: Number((r as unknown as { user_id?: unknown }).user_id ?? 0), plan_id: Number((r as unknown as { plan_id?: unknown }).plan_id ?? 0), status: String((r as unknown as { status?: unknown }).status ?? ''), gateway: String((r as unknown as { gateway?: unknown }).gateway ?? ''), current_period_end: (r as unknown as { current_period_end?: unknown }).current_period_end === null ? null : String((r as unknown as { current_period_end?: unknown }).current_period_end ?? ''), })) .filter((r) => r.id > 0); const userIds = Array.from(new Set(normSubs.map((s) => s.user_id).filter((id) => id > 0))); const users = userIds.length > 0 ? await getWpApi().get( `/users?context=edit&per_page=100&include=${encodeURIComponent(userIds.join(','))}` ) : ([] as WpRestUser[]); const opts = settingsResp && typeof settingsResp === 'object' && 'options' in settingsResp ? settingsResp.options : {}; const manualGrant = opts && typeof opts === 'object' && 'allow_manual_subscription_grant' in opts ? opts.allow_manual_subscription_grant !== false : true; const storeCurrency = paymentValues && typeof paymentValues === 'object' && 'data' in paymentValues ? String(((paymentValues as any).data?.values?.currency as unknown) || 'USD').toUpperCase() : 'USD'; return { ok: true, subscriptions: normSubs, plans: normPlans, users, manualGrantAllowed: manualGrant, storeCurrency }; }, [enabled]); const { loading, data, error, refetch } = useAsyncData(loader, [enabled]); const rows = Array.isArray(data?.subscriptions) ? data.subscriptions : []; const plans = Array.isArray(data?.plans) ? data.plans : []; const planById = useMemo(() => new Map(plans.map((p) => [p.id, p])), [plans]); const users = Array.isArray((data as unknown as { users?: WpRestUser[] } | undefined)?.users) ? (data as unknown as { users: WpRestUser[] }).users : []; const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]); const manualGrantAllowed = (data as unknown as { manualGrantAllowed?: boolean } | undefined)?.manualGrantAllowed !== false; return ( refetch()}> Refresh ) : null } > addon.enable()} addonError={addon.error} >
{error && activeTab !== 'settings' ? ( refetch()} /> ) : activeTab === 'settings' ? ( ) : activeTab === 'plans' ? ( ) : ( setActiveTab('plans')} onJumpToSettings={() => setActiveTab('settings')} onToast={pushToast} /> )} {/* Toast */}
); } function PlansTab(props: { loading: boolean; plans: Plan[]; storeCurrency: string; onRefetch: () => void; onToast: (t: ToastState) => void; }) { const { loading, plans, storeCurrency, onRefetch, onToast } = props; const dialog = useSikshyaDialog(); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(0); const [selectedIds, setSelectedIds] = useState([]); const [bulkAction, setBulkAction] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const [name, setName] = useState(''); const [amount, setAmount] = useState('29'); const [interval, setInterval] = useState<'month' | 'year'>('month'); const [status, setStatus] = useState<'active' | 'inactive'>('active'); useEffect(() => { setSelectedIds([]); setBulkAction(''); }, [plans.length]); const toggleAll = (checked: boolean) => { setSelectedIds(checked ? plans.map((p) => p.id) : []); }; const toggleOne = (id: number, checked: boolean) => { setSelectedIds((prev) => { const s = new Set(prev); if (checked) s.add(id); else s.delete(id); return Array.from(s); }); }; const applyBulk = async () => { if (bulkBusy || selectedIds.length === 0 || !bulkAction) return; if (bulkAction === 'delete') { const ok = await dialog.confirm({ title: `Delete ${selectedIds.length} plan(s)?`, message: __('Plans with existing subscriptions will be skipped. This cannot be undone.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; } setBulkBusy(true); try { if (bulkAction.startsWith('status:')) { const st = bulkAction.replace(/^status:/, ''); await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.plansBulk, { action: 'status', status: st, ids: selectedIds }); } else if (bulkAction === 'delete') { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.plansBulk, { action: 'delete', ids: selectedIds }); } setSelectedIds([]); setBulkAction(''); onRefetch(); window.setTimeout(() => onRefetch(), 350); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not apply bulk action.' }); } finally { setBulkBusy(false); } }; const columns: Column[] = useMemo( () => [ { id: 'select', header: ( 0 && selectedIds.length === plans.length} onChange={(e) => toggleAll(e.target.checked)} /> ), alwaysVisible: true, headerClassName: 'w-10', cellClassName: 'w-10', render: (p) => ( toggleOne(p.id, e.target.checked)} /> ), }, { id: 'id', header: 'ID', alwaysVisible: true, cellClassName: 'whitespace-nowrap tabular-nums text-slate-600 dark:text-slate-400', render: (p) => p.id, }, { id: 'plan', header: 'Plan', alwaysVisible: true, render: (p) => (
{p.name}
#{p.id}
), }, { id: 'price', header: 'Price', cellClassName: 'whitespace-nowrap tabular-nums', render: (p) => `${Number.isFinite(p.amount) ? p.amount.toFixed(2) : String(p.amount)} ${p.currency}`, }, { id: 'interval', header: 'Interval', cellClassName: 'whitespace-nowrap capitalize text-slate-700 dark:text-slate-300', render: (p) => p.interval_unit, }, { id: 'status', header: 'Status', cellClassName: 'whitespace-nowrap capitalize text-slate-700 dark:text-slate-300', render: (p) => p.status, }, { id: 'actions', header: '', headerClassName: 'w-10', cellClassName: 'w-10 whitespace-nowrap text-right', render: (p) => ( { setEditingId(p.id); setName(p.name || ''); setAmount(String(p.amount ?? 0)); setInterval(p.interval_unit === 'year' ? 'year' : 'month'); setStatus(p.status === 'inactive' ? 'inactive' : 'active'); setEditOpen(true); }, }, { key: 'delete', label: 'Delete', danger: true, onClick: async () => { const ok = await dialog.confirm({ title: `Delete plan “${p.name}”?`, message: __('This cannot be undone.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.pro.plan(p.id)); onRefetch(); onToast({ kind: 'success', text: 'Plan deleted.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not delete plan.' }); } }, }, ]} /> ), }, ], [dialog, onRefetch, plans.length, selectedIds, storeCurrency] ); return ( <>

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

Define the price and billing interval that subscriptions attach to.

{ setCreateOpen(true); }} > Add plan
{loading ? ( ) : ( <>
void applyBulk()} applyBusy={bulkBusy} trashMode={false} customOptions={[ { value: 'delete', label: 'Delete permanently' }, { value: 'status:active', label: 'Mark active' }, { value: 'status:inactive', label: 'Mark inactive' }, ]} selectId="plans-bulk" />
{selectedIds.length > 0 ? `${selectedIds.length} selected` : ''}
p.id} emptyContent={ } /> )}
setCreateOpen(false)} footer={
{saving ? __('Saving…', 'sikshya') : __('Create plan', 'sikshya')}
} >
{ e.preventDefault(); setSaving(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.plans, { name, amount: parseFloat(amount) || 0, interval_unit: interval, status, }); setName(''); setCreateOpen(false); onRefetch(); window.setTimeout(() => onRefetch(), 350); onToast({ kind: 'success', text: 'Plan created.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not create plan.' }); } finally { setSaving(false); } }} >
setEditOpen(false)} footer={
{saving ? __('Saving…', 'sikshya') : __('Save changes', 'sikshya')}
} >
{ e.preventDefault(); if (editingId <= 0) return; setSaving(true); try { await getSikshyaApi().put(SIKSHYA_ENDPOINTS.pro.plan(editingId), { name, amount: parseFloat(amount) || 0, interval_unit: interval, status, }); setEditOpen(false); onRefetch(); window.setTimeout(() => onRefetch(), 350); onToast({ kind: 'success', text: 'Plan updated.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not update plan.' }); } finally { setSaving(false); } }} >
); } function SubscriptionsTab(props: { loading: boolean; rows: SubRow[]; plans: Plan[]; planById: Map; userById: Map; manualGrantAllowed: boolean; onRefetch: () => void; onJumpToPlans: () => void; onJumpToSettings: () => void; onToast: (t: ToastState) => void; }) { const { loading, rows, plans, planById, userById, manualGrantAllowed, onRefetch, onJumpToPlans, onJumpToSettings, onToast } = props; const dialog = useSikshyaDialog(); const [open, setOpen] = useState(false); const [creating, setCreating] = useState(false); const [pickedUser, setPickedUser] = useState(null); const [userQuery, setUserQuery] = useState(''); const [userResults, setUserResults] = useState([]); const [userLoading, setUserLoading] = useState(false); const [userOpen, setUserOpen] = useState(false); const [pickedPlanId, setPickedPlanId] = useState(0); const [planQuery, setPlanQuery] = useState(''); const [planOpen, setPlanOpen] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [bulkAction, setBulkAction] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const canAdd = plans.length > 0 && manualGrantAllowed; useEffect(() => { setSelectedIds([]); setBulkAction(''); }, [rows.length]); const toggleAll = (checked: boolean) => { setSelectedIds(checked ? rows.map((r) => r.id) : []); }; const toggleOne = (id: number, checked: boolean) => { setSelectedIds((prev) => { const s = new Set(prev); if (checked) s.add(id); else s.delete(id); return Array.from(s); }); }; const applyBulk = async () => { if (bulkBusy || selectedIds.length === 0 || !bulkAction) return; if (bulkAction === 'delete') { const ok = await dialog.confirm({ title: `Delete ${selectedIds.length} subscription(s)?`, message: __('This permanently removes the subscription rows. This cannot be undone.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; } setBulkBusy(true); try { if (bulkAction.startsWith('status:')) { const st = bulkAction.replace(/^status:/, ''); await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.subscriptionsBulk, { action: 'status', status: st, ids: selectedIds }); } else if (bulkAction === 'delete') { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.subscriptionsBulk, { action: 'delete', ids: selectedIds }); } setSelectedIds([]); setBulkAction(''); onRefetch(); window.setTimeout(() => onRefetch(), 350); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not apply bulk action.' }); } finally { setBulkBusy(false); } }; const filteredPlans = useMemo(() => { const q = planQuery.trim().toLowerCase(); if (!q) return plans; return plans.filter((p) => `${p.name} ${p.id}`.toLowerCase().includes(q)); }, [planQuery, plans]); useEffect(() => { if (!open) { return; } const q = userQuery.trim(); if (q.length < 2) { setUserResults([]); return; } let alive = true; const t = window.setTimeout(async () => { setUserLoading(true); try { const users = await getWpApi().get( `/users?context=edit&per_page=20&search=${encodeURIComponent(q)}` ); if (alive) { setUserResults(Array.isArray(users) ? users : []); } } catch { if (alive) { setUserResults([]); } } finally { if (alive) { setUserLoading(false); } } }, 250); return () => { alive = false; window.clearTimeout(t); }; }, [open, userQuery]); const columns: Column[] = useMemo( () => [ { id: 'select', header: ( 0 && selectedIds.length === rows.length} onChange={(e) => toggleAll(e.target.checked)} /> ), alwaysVisible: true, headerClassName: 'w-10', cellClassName: 'w-10', render: (r) => ( toggleOne(r.id, e.target.checked)} /> ), }, { id: 'id', header: 'ID', alwaysVisible: true, cellClassName: 'whitespace-nowrap tabular-nums text-slate-600 dark:text-slate-400', render: (r) => r.id, }, { id: 'user', header: 'User', alwaysVisible: true, render: (r) => userById.get(r.user_id) ? (
{userById.get(r.user_id)!.name || userById.get(r.user_id)!.slug}
{userById.get(r.user_id)!.email} · #{r.user_id}
) : ( #{r.user_id} ), }, { id: 'plan', header: 'Plan', alwaysVisible: true, render: (r) => planById.get(r.plan_id) ? (
{planById.get(r.plan_id)!.name}
#{r.plan_id} · {planById.get(r.plan_id)!.amount.toFixed(2)} {planById.get(r.plan_id)!.currency} /{' '} {planById.get(r.plan_id)!.interval_unit}
) : ( #{r.plan_id} ), }, { id: 'status', header: 'Status', cellClassName: 'whitespace-nowrap capitalize', render: (r) => r.status }, { id: 'gateway', header: 'Gateway', cellClassName: 'whitespace-nowrap', render: (r) => r.gateway || '—' }, { id: 'period', header: 'Period end', cellClassName: 'whitespace-nowrap text-slate-600 dark:text-slate-400', render: (r) => r.current_period_end || '—', }, { id: 'actions', header: '', headerClassName: 'w-10', cellClassName: 'w-10 whitespace-nowrap text-right', render: (r) => ( { const ok = await dialog.confirm({ title: __('Cancel subscription?', 'sikshya'), message: __('This marks the row cancelled. It does not contact your gateway.', 'sikshya'), confirmLabel: __('Cancel subscription', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.subscriptionsCancel, { id: r.id }); onRefetch(); onToast({ kind: 'success', text: 'Subscription cancelled.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not cancel subscription.' }); } }, }, ] : []), { key: 'delete', label: 'Delete', danger: true, onClick: async () => { const ok = await dialog.confirm({ title: `Delete subscription #${r.id}?`, message: __('This permanently removes the subscription row. This cannot be undone.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.pro.subscription(r.id)); onRefetch(); onToast({ kind: 'success', text: 'Subscription deleted.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not delete subscription.' }); } }, }, ]} /> ), }, ], [dialog, onRefetch, planById, rows.length, selectedIds, userById] ); return ( <>

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

Manual subscriptions are useful for offline renewals or admin grants.

{ setPickedUser(null); setUserQuery(''); setUserResults([]); setUserOpen(false); setPlanQuery(''); setPlanOpen(false); setPickedPlanId(plans[0]?.id ?? 0); setOpen(true); }} > Add subscription
{!manualGrantAllowed && !loading ? (
Manual subscription grants are turned off under the{' '} {' '} tab. You can still cancel existing rows below. Checkout fulfillment and gateways are unchanged.
) : null} {!canAdd && manualGrantAllowed && !loading ? (
You need at least one plan before you can create a subscription.{' '} .
) : null} {loading ? ( ) : ( <>
void applyBulk()} applyBusy={bulkBusy} trashMode={false} customOptions={[ { value: 'delete', label: 'Delete permanently' }, { value: 'status:active', label: 'Mark active' }, { value: 'status:cancelled', label: 'Mark cancelled' }, ]} selectId="subs-bulk" />
{selectedIds.length > 0 ? `${selectedIds.length} selected` : ''}
r.id} emptyContent={ } /> )}
setOpen(false)} footer={
{pickedUser ? ( <> Selected: {pickedUser.name || pickedUser.slug} · #{pickedUser.id} ) : ( 'Select a user to continue.' )}
{creating ? __('Creating…', 'sikshya') : __('Create subscription', 'sikshya')}
} >
{ e.preventDefault(); if (!pickedUser || pickedPlanId <= 0) { return; } setCreating(true); try { const created = await getSikshyaApi().post<{ ok?: boolean; id?: number; subscription?: SubRow; message?: string; db_error?: string; }>(SIKSHYA_ENDPOINTS.pro.subscriptions, { user_id: pickedUser.id, plan_id: pickedPlanId, }); if (created && created.ok === false) { throw new Error(created.message || 'Could not create subscription.'); } setOpen(false); onRefetch(); window.setTimeout(() => onRefetch(), 350); onToast({ kind: 'success', text: 'Subscription created.' }); } catch (e) { onToast({ kind: 'error', text: getErrorSummary(e) || 'Could not create subscription.' }); } finally { setCreating(false); } }} >
{__('User', 'sikshya')}
{ setPickedUser(null); setUserQuery(e.target.value); setUserOpen(true); }} onFocus={() => setUserOpen(true)} placeholder={__('Search users by name or email…', 'sikshya')} className="w-full rounded-lg border border-slate-200 px-3 py-2 dark:border-slate-700 dark:bg-slate-950" /> {userOpen ? (
{userLoading ? (
{__('Searching…', 'sikshya')}
) : userQuery.trim().length < 2 ? (
{__('Type at least 2 characters.', 'sikshya')}
) : userResults.length === 0 ? (
{__('No users found.', 'sikshya')}
) : ( userResults.map((u) => ( )) )}
) : null}
{pickedUser ? (
Selected user: #{pickedUser.id}
) : null}
{__('Plan', 'sikshya')}
0 && planById.get(pickedPlanId) ? `${planById.get(pickedPlanId)!.name} · ${planById.get(pickedPlanId)!.amount.toFixed(2)} ${ planById.get(pickedPlanId)!.currency }/${planById.get(pickedPlanId)!.interval_unit} · #${pickedPlanId}` : planQuery } onChange={(e) => { setPickedPlanId(0); setPlanQuery(e.target.value); setPlanOpen(true); }} onFocus={() => setPlanOpen(true)} placeholder={__('Search plans…', 'sikshya')} className="w-full rounded-lg border border-slate-200 px-3 py-2 dark:border-slate-700 dark:bg-slate-950" /> {planOpen ? (
{filteredPlans.length === 0 ? (
{__('No plans found.', 'sikshya')}
) : ( filteredPlans.map((p) => ( )) )}
) : null}
); }