import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { getSikshyaApi } from '../api'; import { appViewHref } from '../lib/appUrl'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ButtonPrimary } from '../components/shared/buttons'; import { SkeletonCard } from '../components/shared/Skeleton'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; 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 FieldType = 'string' | 'password' | 'textarea' | 'bool' | 'int' | 'select' | 'csv'; type FieldDef = { type: FieldType; label: string; default: unknown; help?: string; choices?: Record | null; min?: number | null; max?: number | null; }; type Schema = Record; type Resp = { ok?: boolean; addon?: string; options?: Record; schema?: Schema; }; export type AddonSettingsPageProps = { config: SikshyaReactConfig; /** Page title rendered in the AppShell header. */ title: string; /** Addon id, e.g. "live_classes". */ addonId: string; /** Short subtitle under the title. */ subtitle: string; /** Headline shown in the gated workspace. */ featureTitle: string; /** One-paragraph "what is this" description. */ featureDescription: string; /** Optional list of "next steps" rendered below the form to guide the noob. */ nextSteps?: { label: string; href?: string; description?: string }[]; /** Render extra section above the schema form (e.g. provider hints). */ preformSection?: React.ReactNode; /** When true, omit the AppShell wrapper (the parent owns the shell). */ embedded?: boolean; /** When false, hides the “what you are editing” callout (parent already explained scope). */ showSettingsScopeCallout?: boolean; /** * If set, the callout links to Settings with this tab for overlapping core behaviour * (e.g. `quizzes` when this add-on extends random pools). */ relatedCoreSettingsTab?: string; /** Label for the Settings deep link; defaults to a title-cased `relatedCoreSettingsTab`. */ relatedCoreSettingsLabel?: string; }; /** * Generic schema-driven settings page for addons that previously had no React UI. * Backed by `/sikshya/v1/pro/addons//settings`. */ function humanizeSettingsTab(tab: string): string { const t = tab.replace(/_/g, ' ').trim(); if (!t) return 'Settings'; return t.replace(/\b\w/g, (c) => c.toUpperCase()); } export function AddonSettingsPage(props: AddonSettingsPageProps) { const { config, title, addonId, subtitle, featureTitle, featureDescription, nextSteps, preformSection, embedded, showSettingsScopeCallout = true, relatedCoreSettingsTab, relatedCoreSettingsLabel, } = props; const featureOk = isFeatureEnabled(config, addonId); const addon = useAddonEnabled(addonId); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const [opts, setOpts] = useState>({}); const [schema, setSchema] = useState({}); const [saving, setSaving] = useState(false); const toast = useTopRightToast(); const loader = useCallback(async () => { if (!enabled) return { ok: true, options: {}, schema: {} } as Resp; return getSikshyaApi().get(`/pro/addons/${encodeURIComponent(addonId)}/settings`); }, [enabled, addonId]); const { loading, data, error, refetch } = useAsyncData(loader, [enabled, addonId]); useEffect(() => { if (data?.options) setOpts({ ...data.options }); if (data?.schema) setSchema(data.schema); }, [data]); const onSave = async (e: FormEvent) => { e.preventDefault(); setSaving(true); try { await getSikshyaApi().post(`/pro/addons/${encodeURIComponent(addonId)}/settings`, opts); toast.success(__('Saved', 'sikshya'), 'Settings saved.'); void refetch(); } catch (err) { toast.error(__('Save failed', 'sikshya'), err instanceof Error ? err.message : 'Save failed'); } finally { setSaving(false); } }; const setField = (name: string, value: unknown) => setOpts((prev) => ({ ...prev, [name]: value })); const renderField = (name: string, def: FieldDef) => { const value = opts[name]; // Canonical form-control class: matches shared/form/FormInput so this page // gets the same focus ring, dark-mode background, and disabled state as // the rest of the admin. Kept as a string here (vs. ) because // the renderField switch is small and the constant version is the // least-invasive migration. const inputClass = 'mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 ' + 'placeholder:text-slate-400 ' + '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 dark:placeholder:text-slate-500'; switch (def.type) { case 'bool': return ( ); case 'select': return ( ); case 'int': return ( ); case 'textarea': return (