import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { AddonSettingsPage } from './AddonSettingsPage'; 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 { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { MultiCoursePicker } from '../components/shared/MultiCoursePicker'; import { DateTimePickerField } from '../components/shared/DateTimePickerField'; import { HorizontalEditorTabs } from '../components/shared/HorizontalEditorTabs'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type CouponRow = { id: number; code: string; discount_type: string; discount_value: number; max_uses: number; used_count: number; expires_at: string | null; status: string; }; type ListResponse = { ok?: boolean; coupons?: CouponRow[]; table_missing?: boolean; }; type AdvancedRulesPayload = { min_subtotal: number; max_subtotal: number; allowed_course_ids: number[]; excluded_course_ids: number[]; max_discount_amount: number; per_user_limit: number; first_order_only: boolean; valid_from: string; valid_until: string; }; type AdvancedRulesApi = Partial<{ min_subtotal: number | null; max_subtotal: number | null; allowed_course_ids: number[]; excluded_course_ids: number[]; max_discount_amount: number | null; per_user_limit: number | null; first_order_only: boolean; valid_from: string | null; valid_until: string | null; }>; type AdvancedMetaResponse = { ok?: boolean; meta?: Record; rules?: AdvancedRulesApi; }; type EditorMode = 'create' | 'manage'; type PageTab = 'list' | 'create' | 'manage' | 'settings'; function clampNumberInput(v: string, fallback = 0) { const n = parseFloat(v); return Number.isFinite(n) ? n : fallback; } export function CouponsPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const [tab, setTab] = useState('list'); const [selected, setSelected] = useState(null); const [search, setSearch] = useState(''); const [code, setCode] = useState(''); const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent'); const [discountValue, setDiscountValue] = useState('10'); const [maxUses, setMaxUses] = useState('0'); const [expiresAt, setExpiresAt] = useState(''); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); const advFeature = isFeatureEnabled(config, 'coupons_advanced'); const advAddon = useAddonEnabled('coupons_advanced'); const advMode = resolveGatedWorkspaceMode(advFeature, advAddon.enabled, advAddon.loading); const advEnabled = advMode === 'full'; const [advCouponId, setAdvCouponId] = useState(null); const [advMin, setAdvMin] = useState(''); const [advMaxSub, setAdvMaxSub] = useState(''); const [advCourseIds, setAdvCourseIds] = useState([]); const [advExcludedCourseIds, setAdvExcludedCourseIds] = useState([]); const [advMaxDiscount, setAdvMaxDiscount] = useState(''); const [advPerUser, setAdvPerUser] = useState(''); const [advFirstOrder, setAdvFirstOrder] = useState(false); const [advValidFrom, setAdvValidFrom] = useState(''); const [advValidUntil, setAdvValidUntil] = useState(''); const [advLoading, setAdvLoading] = useState(false); const [advSaving, setAdvSaving] = useState(false); const [advMsg, setAdvMsg] = useState(null); const loader = useCallback(async () => { return getSikshyaApi().get(SIKSHYA_ENDPOINTS.admin.coupons); }, []); const { loading, data, error, refetch } = useAsyncData(loader, []); const rows = data?.coupons ?? []; const tableMissing = Boolean(data?.table_missing); // Storefront `enable_coupons` flag — coupons silently drop at checkout when off. const [checkoutEnabled, setCheckoutEnabled] = useState(null); const [checkoutToggleBusy, setCheckoutToggleBusy] = useState(false); useEffect(() => { void (async () => { try { const r = await getSikshyaApi().get<{ enabled?: boolean }>( SIKSHYA_ENDPOINTS.admin.couponsCheckoutToggle ); setCheckoutEnabled(Boolean(r?.enabled)); } catch { setCheckoutEnabled(null); } })(); }, []); const enableCouponsAtCheckout = async () => { setCheckoutToggleBusy(true); try { const r = await getSikshyaApi().post<{ enabled?: boolean }>( SIKSHYA_ENDPOINTS.admin.couponsCheckoutToggle, { enabled: true } ); setCheckoutEnabled(Boolean(r?.enabled)); } finally { setCheckoutToggleBusy(false); } }; useEffect(() => { if (!advCouponId || !advEnabled) return; setAdvLoading(true); setAdvMsg(null); void (async () => { try { const r = await getSikshyaApi().get( SIKSHYA_ENDPOINTS.pro.couponAdvanced(advCouponId) ); const rules = r.rules ?? {}; setAdvMin(rules.min_subtotal != null && rules.min_subtotal > 0 ? String(rules.min_subtotal) : ''); setAdvMaxSub(rules.max_subtotal != null && rules.max_subtotal > 0 ? String(rules.max_subtotal) : ''); setAdvCourseIds( Array.isArray(rules.allowed_course_ids) ? rules.allowed_course_ids.filter((n: number) => n > 0) : [] ); setAdvExcludedCourseIds( Array.isArray(rules.excluded_course_ids) ? rules.excluded_course_ids.filter((n: number) => n > 0) : [] ); setAdvMaxDiscount( rules.max_discount_amount != null && rules.max_discount_amount > 0 ? String(rules.max_discount_amount) : '' ); setAdvPerUser(rules.per_user_limit != null && rules.per_user_limit > 0 ? String(rules.per_user_limit) : ''); setAdvFirstOrder(Boolean(rules.first_order_only)); setAdvValidFrom(rules.valid_from ? String(rules.valid_from) : ''); setAdvValidUntil(rules.valid_until ? String(rules.valid_until) : ''); } catch (err) { setAdvMsg(err instanceof Error ? err.message : 'Could not load advanced rules.'); } finally { setAdvLoading(false); } })(); }, [advCouponId, advEnabled]); const buildAdvancedRulesPayload = (): AdvancedRulesPayload => ({ min_subtotal: clampNumberInput(advMin, 0), max_subtotal: clampNumberInput(advMaxSub, 0), allowed_course_ids: advCourseIds, excluded_course_ids: advExcludedCourseIds, max_discount_amount: clampNumberInput(advMaxDiscount, 0), per_user_limit: Math.max(0, parseInt(advPerUser, 10) || 0), first_order_only: advFirstOrder, valid_from: advValidFrom.trim(), valid_until: advValidUntil.trim(), }); const saveAdvanced = async () => { if (!advCouponId) return; setAdvSaving(true); setAdvMsg(null); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.couponAdvanced(advCouponId), { rules: buildAdvancedRulesPayload(), }); setAdvMsg(__('Advanced rules saved.', 'sikshya')); } catch (err) { setAdvMsg(err instanceof Error ? err.message : 'Save failed'); } finally { setAdvSaving(false); } }; const resetEditor = () => { setCode(''); setDiscountType('percent'); setDiscountValue('10'); setMaxUses('0'); setExpiresAt(''); setSaveMsg(null); setAdvCouponId(null); setAdvMin(''); setAdvMaxSub(''); setAdvCourseIds([]); setAdvExcludedCourseIds([]); setAdvMaxDiscount(''); setAdvPerUser(''); setAdvFirstOrder(false); setAdvValidFrom(''); setAdvValidUntil(''); setAdvMsg(null); }; const beginCreate = () => { resetEditor(); setSelected(null); setTab('create'); }; const beginManage = (row: CouponRow) => { setSelected(row); setTab('manage'); setSaveMsg(null); setCode(row.code); setDiscountType((row.discount_type as 'percent' | 'fixed') || 'percent'); setDiscountValue(String(row.discount_value ?? 0)); setMaxUses(String(row.max_uses ?? 0)); setExpiresAt(row.expires_at ? String(row.expires_at) : ''); setAdvCouponId(row.id); setAdvMsg(null); }; const editorMode: EditorMode = tab === 'manage' ? 'manage' : 'create'; const onSubmit = async (e: FormEvent) => { e.preventDefault(); setSaveMsg(null); setSaving(true); try { if (editorMode === 'manage' && selected) { await getSikshyaApi().patch(SIKSHYA_ENDPOINTS.admin.coupon(selected.id), { code: code.trim(), discount_type: discountType, discount_value: clampNumberInput(discountValue, 0), max_uses: parseInt(maxUses, 10) || 0, expires_at: expiresAt || null, status: selected.status || 'active', }); setSaveMsg(__('Coupon updated.', 'sikshya')); await refetch(); return; } const r = await getSikshyaApi().post<{ ok?: boolean; id?: number }>(SIKSHYA_ENDPOINTS.admin.coupons, { code: code.trim(), discount_type: discountType, discount_value: clampNumberInput(discountValue, 0), max_uses: parseInt(maxUses, 10) || 0, expires_at: expiresAt || null, status: 'active', }); const createdId = Number(r?.id) || 0; if (createdId > 0 && advEnabled) { const rules = buildAdvancedRulesPayload(); const hasAdvanced = (rules.min_subtotal ?? 0) > 0 || (rules.max_subtotal ?? 0) > 0 || (rules.allowed_course_ids?.length ?? 0) > 0 || (rules.excluded_course_ids?.length ?? 0) > 0 || (rules.max_discount_amount ?? 0) > 0 || (rules.per_user_limit ?? 0) > 0 || rules.first_order_only || (rules.valid_from && String(rules.valid_from).trim() !== '') || (rules.valid_until && String(rules.valid_until).trim() !== ''); if (hasAdvanced) { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.couponAdvanced(createdId), { rules }); } } setSaveMsg(__('Coupon created.', 'sikshya')); await refetch(); setTab('list'); resetEditor(); } catch (err) { setSaveMsg(err instanceof Error ? err.message : 'Could not create coupon.'); } finally { setSaving(false); } }; const visibleRows = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return rows; return rows.filter((r) => r.code.toLowerCase().includes(q)); }, [rows, search]); const tabs = useMemo(() => { const items = [ { id: 'list', label: 'Coupons' }, { id: 'create', label: 'Create coupon' }, ] as { id: PageTab; label: string }[]; if (advFeature) { items.push({ id: 'settings', label: 'Add-on defaults' }); } if (tab === 'manage' && selected) { items.push({ id: 'manage', label: `Manage: ${selected.code}` }); } return items; }, [tab, selected, advFeature]); return ( refetch()}> Refresh } > {error ? (
refetch()} />
) : null} {tableMissing ? (
Coupons table is not installed yet. Update the plugin to run database migrations.
) : null} {checkoutEnabled === false ? (
{__('Coupons are off at checkout', 'sikshya')}
{__( 'Codes won’t apply at checkout until you switch this on. Existing codes here will still show in this list, but learners can’t redeem them.', 'sikshya' )}
void enableCouponsAtCheckout()} > {checkoutToggleBusy ? __('Enabling…', 'sikshya') : __('Enable coupons', 'sikshya')}
) : null}
{ const next = id as PageTab; if (next === 'list') { setSelected(null); setTab('list'); return; } if (next === 'create') { beginCreate(); return; } if (next === 'settings') { setSelected(null); setTab('settings'); resetEditor(); return; } if (next === 'manage' && selected) { beginManage(selected); } }} ariaLabel={__('Coupons tabs', 'sikshya')} />
{tab === 'settings' ? ( ) : tab === 'list' ? (
{__('All coupons', 'sikshya')}
Tip: click “Manage” on a coupon to view advanced rules (and set them if you have Advanced coupons).
setSearch(e.target.value)} placeholder={__('Search code…', 'sikshya')} className="w-56 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white" /> Create coupon
{loading ? (
{__('Loading…', 'sikshya')}
) : visibleRows.length === 0 ? ( ) : (
{visibleRows.map((r) => ( ))}
{__('Code', 'sikshya')} {__('Discount', 'sikshya')} {__('Uses', 'sikshya')} {__('Expires', 'sikshya')} {__('Status', 'sikshya')} {__('Actions', 'sikshya')}
{r.code} {r.discount_type === 'percent' ? `${r.discount_value}%` : `${r.discount_value.toFixed(2)} fixed`} {r.used_count} {r.max_uses > 0 ? ` / ${r.max_uses}` : ''} {r.expires_at || '—'} {r.status} beginManage(r)}> Manage
)}
) : (

{editorMode === 'create' ? 'Create coupon' : `Manage coupon: ${selected?.code || ''}`}

{editorMode === 'create' ? 'Create a basic coupon, then optionally add advanced rules (cart thresholds, course targeting, limits, schedule).' : 'Edit the code, discount, and limits above. Advanced targeting and caps are below.'}

{tab === 'manage' ? ( { setTab('list'); setSelected(null); resetEditor(); }} > Back to list ) : null}
{saveMsg ?

{saveMsg}

: null}
advAddon.enable()} addonError={advAddon.error} > {editorMode === 'create' || (editorMode === 'manage' && selected) ? (

{editorMode === 'create' ? 'Advanced rules (optional)' : `Advanced rules for ${selected?.code}`}

Leave fields empty or zero to disable that rule. Course exclude list blocks the code if any excluded course is in the cart.

{editorMode === 'manage' && advLoading ?

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

: null}
{__('Allowed courses', 'sikshya')}

If set, the cart must include at least one of these courses.

{__('Excluded courses', 'sikshya')}

If the cart contains any of these courses, the code is rejected.

{editorMode === 'manage' && selected ? (
void saveAdvanced()} disabled={advSaving || !advCouponId}> {advSaving ? __('Saving…', 'sikshya') : __('Save advanced rules', 'sikshya')} {advMsg ? {advMsg} : null}
) : (

Advanced rules save automatically when you create the coupon.

)}
) : ( )}
{editorMode === 'create' ? ( { setTab('list'); resetEditor(); }} disabled={saving} > Cancel ) : null} {saving ? 'Saving…' : editorMode === 'create' ? __('Create coupon', 'sikshya') : __('Save coupon', 'sikshya')}
)}
); }