import { useCallback, useMemo, useState, type FormEvent } from 'react'; import { getSikshyaApi, 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 { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { __ } from '../lib/i18n'; type OverviewResp = { ok?: boolean; totals?: { platform_commission: number; vendor_net: number; gross: number; count: number }; pending_withdrawals?: number; vendor_counts?: { all: number; pending: number; active: number; suspended: number }; currency?: string; }; type Vendor = { id: number; user_id: number; user_email: string; user_display: string; store_slug: string; display_name: string; bio?: string; status: 'pending' | 'active' | 'suspended' | string; payout_method?: string; commission_override?: number | null; created_at: string; updated_at: string; }; type VendorsResp = { ok?: boolean; rows?: Vendor[]; total?: number; page?: number; per_page?: number; }; type Commission = { id: number; order_id: number; course_id: number; course_title: string; vendor_user_id: number; vendor_name: string; currency: string; gross: number; platform_fee: number; platform_commission: number; vendor_net: number; rate: number; status: string; available_at: string; paid_at: string; created_at: string; }; type CommissionsResp = { ok?: boolean; rows?: Commission[]; total?: number; totals?: { platform_commission: number; vendor_net: number; gross: number; count: number }; currency?: string; }; type Withdrawal = { id: number; vendor_user_id: number; vendor_name: string; amount: number; currency: string; method: string; status: 'pending' | 'approved' | 'rejected' | 'paid' | 'cancelled' | string; notes?: string; admin_notes?: string; created_at: string; decided_at?: string; paid_at?: string; }; type WithdrawalsResp = { ok?: boolean; rows?: Withdrawal[]; total?: number; currency?: string; }; type TabId = 'overview' | 'vendors' | 'commissions' | 'withdrawals'; const TABS: { id: TabId; label: string; hint: string }[] = [ { id: 'overview', label: 'Overview', hint: 'Platform earnings + queue' }, { id: 'vendors', label: 'Vendors', hint: 'Storefront accounts' }, { id: 'commissions', label: 'Commissions', hint: 'Per-line ledger' }, { id: 'withdrawals', label: 'Withdrawals', hint: 'Vendor payouts' }, ]; const VENDOR_STATUS_LABEL: Record = { pending: 'Pending', active: 'Active', suspended: 'Suspended', }; const COMMISSION_STATUS_LABEL: Record = { accrued: 'Pending hold', available: 'Available', paid: 'Paid', reversed: 'Reversed', }; const WITHDRAWAL_STATUS_LABEL: Record = { pending: 'Pending', approved: 'Approved', rejected: 'Rejected', paid: 'Paid', cancelled: 'Cancelled', }; function fmtCurrency(amount: number, currency: string): string { if (Number.isNaN(amount)) { return `0.00 ${currency}`; } return `${amount.toFixed(2)} ${currency}`; } /** * Marketplace status pill — delegates to shared StatusBadge. `status` is * looked up in the canonical map (which already covers active/pending/paid/ * rejected/reversed/suspended/cancelled/accrued/approved) so the tone is * picked correctly without per-page color logic. The translated `label` * comes from the marketplace's own status-label maps. */ function StatusPill({ status, kind }: { status: string; kind: 'vendor' | 'commission' | 'withdrawal' }) { const label = kind === 'vendor' ? VENDOR_STATUS_LABEL[status] || status : kind === 'commission' ? COMMISSION_STATUS_LABEL[status] || status : WITHDRAWAL_STATUS_LABEL[status] || status; return ; } function StatTile(props: { label: string; value: string; hint?: string }) { return (
{props.label}
{props.value}
{props.hint ?
{props.hint}
: null}
); } export function MarketplacePage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const featureOk = isFeatureEnabled(config, 'marketplace_multivendor'); const addon = useAddonEnabled('marketplace_multivendor'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const [tab, setTab] = useState('overview'); const toast = useTopRightToast(2600); const pushToast = useCallback( (t: { kind: 'success' | 'error'; text: string } | null) => { if (!t) { toast.clear(); return; } if (t.kind === 'success') { toast.success(__('Success', 'sikshya'), t.text); } else { toast.error(__('Error', 'sikshya'), t.text); } }, [toast] ); const overviewLoader = useCallback(async () => { if (!enabled) { return null; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.marketplace.admin.overview); }, [enabled]); const overview = useAsyncData(overviewLoader, [enabled]); const currency = overview.data?.currency ?? 'USD'; return ( addon.enable()} addonError={addon.error} > <>
{TABS.map((t) => ( ))} { void overview.refetch(); }} className="ml-auto" > Refresh
{tab === 'overview' ? ( void overview.refetch()} currency={currency} /> ) : null} {tab === 'vendors' ? : null} {tab === 'commissions' ? : null} {tab === 'withdrawals' ? : null}
); } function OverviewPanel(props: { loading: boolean; data: OverviewResp | null | undefined; error: unknown; onRefresh: () => void; currency: string; }) { const { loading, data, error, onRefresh, currency } = props; if (error) { return ; } if (loading) { return
{__('Loading overview…', 'sikshya')}
; } if (!data) { return null; } const totals = data.totals ?? { platform_commission: 0, vendor_net: 0, gross: 0, count: 0 }; const vendors = data.vendor_counts ?? { all: 0, pending: 0, active: 0, suspended: 0 }; return (
{__('Tip:', 'sikshya')} Commission rows accrue immediately when a paid order fulfills, but only become withdrawable once the hold period passes. You can adjust the hold period in Marketplace settings.
); } function VendorsPanel(props: { enabled: boolean; setToast: (t: { kind: 'success' | 'error'; text: string } | null) => void }) { const { enabled, setToast: pushToast } = props; const [page, setPage] = useState(1); const [status, setStatus] = useState(''); const [search, setSearch] = useState(''); const [busy, setBusy] = useState(null); const queryKey = useMemo(() => `${page}|${status}|${search}`, [page, status, search]); const loader = useCallback(async () => { if (!enabled) { return { ok: true, rows: [] as Vendor[], total: 0 }; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.marketplace.admin.vendors({ page, per_page: 25, status: status || undefined, search: search.trim() || undefined, }) ); }, [enabled, page, status, search]); const v = useAsyncData(loader, [queryKey]); const rows = v.data?.rows ?? []; const total = v.data?.total ?? 0; const setVendorStatus = async (vendor: Vendor, next: 'active' | 'pending' | 'suspended') => { setBusy(vendor.id); pushToast(null); try { await getSikshyaApi().put<{ ok?: boolean }>( SIKSHYA_ENDPOINTS.marketplace.admin.vendor(vendor.id), { status: next } ); pushToast({ kind: 'success', text: `Vendor ${vendor.display_name || vendor.user_display} marked ${next}.`, }); void v.refetch(); } catch (err) { pushToast({ kind: 'error', text: err instanceof Error ? err.message : 'Failed to update vendor.', }); } finally { setBusy(null); } }; return (

{total ? `${total} vendor${total === 1 ? '' : 's'}` : null}

{v.error ? (
void v.refetch()} />
) : v.loading ? (

{__('Loading…', 'sikshya')}

) : rows.length === 0 ? (
) : (
{rows.map((r) => ( ))}
{__('Vendor', 'sikshya')} {__('Store', 'sikshya')} {__('Status', 'sikshya')} Commission % {__('Joined', 'sikshya')} {__('Actions', 'sikshya')}
{r.display_name || r.user_display || `User #${r.user_id}`}
{r.user_email}
{r.store_slug || '—'} {r.commission_override !== null && r.commission_override !== undefined ? `${r.commission_override.toFixed(2)} %` : {__('default', 'sikshya')}} {r.created_at ? r.created_at.replace(' ', ' · ') : '—'} {(() => { const items: RowActionItem[] = []; if (r.status !== 'active') { items.push({ key: 'activate', label: __('Activate', 'sikshya'), onClick: () => void setVendorStatus(r, 'active'), disabled: busy === r.id, }); } if (r.status !== 'suspended') { items.push({ key: 'suspend', label: __('Suspend', 'sikshya'), onClick: () => void setVendorStatus(r, 'suspended'), danger: true, disabled: busy === r.id, }); } return ; })()}
)}
); } function CommissionsPanel(props: { enabled: boolean }) { const { enabled } = props; const [page, setPage] = useState(1); const [status, setStatus] = useState(''); const [vendorId, setVendorId] = useState(''); const queryKey = useMemo(() => `${page}|${status}|${vendorId}`, [page, status, vendorId]); const loader = useCallback(async () => { if (!enabled) { return { ok: true, rows: [] as Commission[], total: 0 }; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.marketplace.admin.commissions({ page, per_page: 50, status: status || undefined, vendor_user_id: vendorId ? Number(vendorId) : undefined, }) ); }, [enabled, page, status, vendorId]); const c = useAsyncData(loader, [queryKey]); const rows = c.data?.rows ?? []; const totals = c.data?.totals ?? { platform_commission: 0, vendor_net: 0, gross: 0, count: 0 }; const currency = c.data?.currency ?? 'USD'; return (
{__('Platform:', 'sikshya')}{fmtCurrency(totals.platform_commission, currency)} {__('Vendors:', 'sikshya')}{fmtCurrency(totals.vendor_net, currency)} {__('Gross:', 'sikshya')}{fmtCurrency(totals.gross, currency)}
{c.error ? (
void c.refetch()} />
) : c.loading ? (

{__('Loading…', 'sikshya')}

) : rows.length === 0 ? (
) : (
{rows.map((r) => ( ))}
{__('When', 'sikshya')} {__('Order #', 'sikshya')} {__('Course', 'sikshya')} {__('Vendor', 'sikshya')} {__('Gross', 'sikshya')} {__('Fee', 'sikshya')} {__('Platform', 'sikshya')} {__('Vendor net', 'sikshya')} {__('Status', 'sikshya')}
{r.created_at} #{r.order_id} {r.course_title || `Course #${r.course_id}`} {r.vendor_name || `User #${r.vendor_user_id}`} {r.gross.toFixed(2)} {r.platform_fee.toFixed(2)} {r.platform_commission.toFixed(2)} {r.vendor_net.toFixed(2)}
)}
); } function WithdrawalsPanel(props: { enabled: boolean; setToast: (t: { kind: 'success' | 'error'; text: string } | null) => void }) { const { enabled, setToast: pushToast } = props; const [page, setPage] = useState(1); const [status, setStatus] = useState('pending'); const [busy, setBusy] = useState(null); const [adjustVendor, setAdjustVendor] = useState(''); const [adjustAmount, setAdjustAmount] = useState(''); const [adjustReason, setAdjustReason] = useState(''); const [adjustBusy, setAdjustBusy] = useState(false); const queryKey = useMemo(() => `${page}|${status}`, [page, status]); const loader = useCallback(async () => { if (!enabled) { return { ok: true, rows: [] as Withdrawal[], total: 0 }; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.marketplace.admin.withdrawals({ page, per_page: 25, status: status || undefined, }) ); }, [enabled, page, status]); const w = useAsyncData(loader, [queryKey]); const rows = w.data?.rows ?? []; const total = w.data?.total ?? 0; const act = async (id: number, action: 'approve' | 'reject' | 'mark-paid') => { setBusy(id); pushToast(null); try { const path = action === 'approve' ? SIKSHYA_ENDPOINTS.marketplace.admin.withdrawalApprove(id) : action === 'reject' ? SIKSHYA_ENDPOINTS.marketplace.admin.withdrawalReject(id) : SIKSHYA_ENDPOINTS.marketplace.admin.withdrawalMarkPaid(id); await getSikshyaApi().post(path, {}); pushToast({ kind: 'success', text: `Withdrawal #${id} ${action.replace('-', ' ')}.` }); void w.refetch(); } catch (err) { pushToast({ kind: 'error', text: err instanceof Error ? err.message : 'Action failed.', }); } finally { setBusy(null); } }; const submitAdjustment = async (e: FormEvent) => { e.preventDefault(); setAdjustBusy(true); pushToast(null); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.marketplace.admin.adjustments, { vendor_user_id: Number(adjustVendor) || 0, amount: parseFloat(adjustAmount) || 0, reason: adjustReason.trim(), }); pushToast({ kind: 'success', text: 'Adjustment recorded.' }); setAdjustAmount(''); setAdjustReason(''); } catch (err) { pushToast({ kind: 'error', text: err instanceof Error ? err.message : 'Could not record adjustment.', }); } finally { setAdjustBusy(false); } }; return (

{total ? `${total} request${total === 1 ? '' : 's'}` : null}

{w.error ? (
void w.refetch()} />
) : w.loading ? (

{__('Loading…', 'sikshya')}

) : rows.length === 0 ? (
) : (
{rows.map((r) => ( ))}
{__('Requested', 'sikshya')} {__('Vendor', 'sikshya')} {__('Amount', 'sikshya')} {__('Method', 'sikshya')} {__('Status', 'sikshya')} {__('Actions', 'sikshya')}
{r.created_at} {r.vendor_name || `User #${r.vendor_user_id}`} {fmtCurrency(r.amount, r.currency)} {r.method?.replace(/_/g, ' ') || '—'} {(() => { const items: RowActionItem[] = []; if (r.status === 'pending') { items.push( { key: 'approve', label: __('Approve', 'sikshya'), onClick: () => void act(r.id, 'approve'), disabled: busy === r.id, }, { key: 'reject', label: __('Reject', 'sikshya'), onClick: () => void act(r.id, 'reject'), danger: true, disabled: busy === r.id, } ); } if (r.status === 'pending' || r.status === 'approved') { items.push({ key: 'paid', label: __('Mark paid', 'sikshya'), onClick: () => void act(r.id, 'mark-paid'), disabled: busy === r.id, }); } return items.length > 0 ? ( ) : ( ); })()}
)}

{__('Manual adjustment', 'sikshya')}

Record a credit (positive) or a debit (negative) for a vendor — for refund clawbacks, bonuses, or one-off corrections. Adjustments affect the vendor's withdrawable balance immediately.

{adjustBusy ? __('Saving…', 'sikshya') : __('Record adjustment', 'sikshya')}
); }