import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import { getSikshyaApi } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { ListPanel } from '../components/shared/list/ListPanel'; import { SkeletonCard } from '../components/shared/Skeleton'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { TopRightToast, useTopRightToast } from '../components/shared/TopRightToast'; import { useAddonEnabled } from '../hooks/useAddons'; import { useAsyncData } from '../hooks/useAsyncData'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __, sprintf } from '../lib/i18n'; type ProviderId = 'mailchimp' | 'mailerlite' | 'brevo' | 'kit'; type FieldType = 'string' | 'password' | 'textarea' | 'bool' | 'int' | 'select' | 'csv' | 'mapping'; type TabId = 'setup' | 'rules' | 'logs' | 'tools'; type MappingRow = { provider: ProviderId; remote_field: string; source: string; }; type FieldDef = { type: FieldType; label: string; default: unknown; help?: string; choices?: Record | null; sources?: Record | null; min?: number | null; max?: number | null; }; type Schema = Record; type Resp = { ok?: boolean; addon?: string; options?: Record; schema?: Schema; }; type RuleRow = { id: number; name: string; description: string; is_active: number | boolean; priority: number; event_key: string; filters_json?: string | null; actions_json?: string | null; created_at?: string; updated_at?: string; }; type LogRow = { id: number; job_id: number; rule_id: number; provider: string; event_key: string; user_id: number; course_id: number; status: string; http_code: number; error_code: string; error_message?: string | null; created_at?: string; }; const BASE_FIELDS = ['api_key', 'audience_id'] as const; const TOGGLE_FIELDS = ['double_opt_in', 'sync_on_enrollment', 'sync_on_completion'] as const; const PROVIDER_ORDER: ProviderId[] = ['mailchimp', 'mailerlite', 'brevo', 'kit']; const PROVIDER_COPY: Record< ProviderId, { name: string; badge: string; description: string; credentialLabel: string; credentialPlaceholder: string; listLabel: string; destinationPlaceholder: string; destinationHelp: string; mappingNoun: string; fieldLabel: string; fieldPlaceholder: string; fieldHint: string; beginnerTip: string; presets: Array<{ remote_field: string; source: string }>; } > = { mailchimp: { name: 'Mailchimp', badge: 'Audience-based', description: 'Best when you already use Mailchimp audiences and merge tags for campaigns and automations.', credentialLabel: 'Mailchimp API key', credentialPlaceholder: 'abcd1234-us21', listLabel: 'Audience', destinationPlaceholder: 'Audience ID', destinationHelp: 'Find this in Audience -> Settings -> Audience name and defaults.', mappingNoun: 'merge fields', fieldLabel: 'Merge field tag', fieldPlaceholder: 'FNAME', fieldHint: 'Examples: `FNAME`, `LNAME`, `COURSE_NAME`, or any merge tag that already exists in your Mailchimp audience.', beginnerTip: 'Start simple: connect one audience, sync on enrollment, then map only FNAME and LNAME first.', presets: [ { remote_field: 'FNAME', source: 'first_name' }, { remote_field: 'LNAME', source: 'last_name' }, { remote_field: 'COURSE', source: 'course_name' }, { remote_field: 'EVENT', source: 'event' }, ], }, mailerlite: { name: 'MailerLite', badge: 'Simple groups', description: 'A clean beginner-friendly setup using one group plus optional custom fields.', credentialLabel: 'MailerLite API token', credentialPlaceholder: 'Paste your MailerLite API token', listLabel: 'Group', destinationPlaceholder: 'Group ID', destinationHelp: 'Open Subscribers -> Groups and copy the group ID you want learners added to.', mappingNoun: 'custom fields', fieldLabel: 'Custom field key', fieldPlaceholder: 'course_name', fieldHint: 'Examples: `name`, `course_name`, `course_id`, or your existing MailerLite custom field keys.', beginnerTip: 'MailerLite is usually the easiest first setup for noob users: one group, basic fields, done.', presets: [ { remote_field: 'name', source: 'full_name' }, { remote_field: 'course_name', source: 'course_name' }, { remote_field: 'course_id', source: 'course_id' }, { remote_field: 'event', source: 'event' }, ], }, brevo: { name: 'Brevo', badge: 'List + attributes', description: 'Great if your team already uses Brevo lists and created contact attributes inside Brevo.', credentialLabel: 'Brevo API key', credentialPlaceholder: 'Paste your Brevo API key', listLabel: 'List', destinationPlaceholder: 'List ID', destinationHelp: 'Use the numeric Brevo list ID. Mapped attributes must already exist in your Brevo account.', mappingNoun: 'contact attributes', fieldLabel: 'Brevo attribute key', fieldPlaceholder: 'FIRSTNAME', fieldHint: 'Examples: `FIRSTNAME`, `LASTNAME`, `COURSE_NAME`, `EVENT`. These attributes must already exist in Brevo.', beginnerTip: 'Before adding mappings in Brevo, create the matching contact attributes inside Brevo first.', presets: [ { remote_field: 'FIRSTNAME', source: 'first_name' }, { remote_field: 'LASTNAME', source: 'last_name' }, { remote_field: 'COURSE_NAME', source: 'course_name' }, { remote_field: 'EVENT', source: 'event' }, ], }, kit: { name: 'Kit (ConvertKit)', badge: 'Form-based', description: 'Use this when you want learners added to a Kit form and optionally enriched with existing custom fields.', credentialLabel: 'Kit API key', credentialPlaceholder: 'Paste your Kit API key', listLabel: 'Form', destinationPlaceholder: 'Form ID', destinationHelp: 'Use the Kit form ID you want new learners added to after subscriber upsert.', mappingNoun: 'custom fields', fieldLabel: 'Kit field name', fieldPlaceholder: 'first_name', fieldHint: 'Examples: `first_name`, `Last name`, `Course Name`, `Event`. Kit ignores custom fields that do not already exist.', beginnerTip: 'For Kit, confirm the basic form subscription first, then add custom fields only after that works.', presets: [ { remote_field: 'first_name', source: 'first_name' }, { remote_field: 'Last name', source: 'last_name' }, { remote_field: 'Course Name', source: 'course_name' }, { remote_field: 'Event', source: 'event' }, ], }, }; function asProvider(value: unknown): ProviderId { if (value === 'mailerlite' || value === 'brevo' || value === 'kit') { return value; } return 'mailchimp'; } function parseMappings(value: unknown): MappingRow[] { if (!Array.isArray(value)) { return []; } return value .filter((row): row is Record => Boolean(row && typeof row === 'object')) .map((row) => ({ provider: asProvider(row.provider), remote_field: typeof row.remote_field === 'string' ? row.remote_field : '', source: typeof row.source === 'string' ? row.source : '', })); } export function EmailMarketingPage(props: { config: SikshyaReactConfig; title: string; embedded?: boolean }) { const { config, title, embedded } = props; const addonId = 'email_marketing'; const featureOk = isFeatureEnabled(config, addonId); const addon = useAddonEnabled(addonId); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const [tab, setTab] = useState('setup'); const [opts, setOpts] = useState>({}); const [schema, setSchema] = useState({}); const [saving, setSaving] = useState(false); const toast = useTopRightToast(); const dialog = useSikshyaDialog(); const [toolUserId, setToolUserId] = useState(''); const [toolCourseId, setToolCourseId] = useState(''); const [toolBusy, setToolBusy] = useState(false); 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 rulesLoader = useCallback(async () => { if (!enabled) return { ok: true, rules: [] as RuleRow[] }; return getSikshyaApi().get<{ ok?: boolean; rules?: RuleRow[]; message?: string }>(`/pro/email-marketing/rules`); }, [enabled]); const logsLoader = useCallback(async () => { if (!enabled) return { ok: true, logs: [] as LogRow[], total: 0, page: 1, per_page: 50, total_pages: 1 }; return getSikshyaApi().get<{ ok?: boolean; logs?: LogRow[]; total?: number; page?: number; per_page?: number; total_pages?: number; }>(`/pro/email-marketing/logs?per_page=50&page=1`); }, [enabled]); const { loading: rulesLoading, data: rulesData, error: rulesError, refetch: refetchRules, } = useAsyncData(rulesLoader, [enabled]); const { loading: logsLoading, data: logsData, error: logsError, refetch: refetchLogs, } = useAsyncData(logsLoader, [enabled]); const rules = useMemo(() => (Array.isArray(rulesData?.rules) ? rulesData?.rules || [] : []), [rulesData]); const logs = useMemo(() => (Array.isArray(logsData?.logs) ? logsData?.logs || [] : []), [logsData]); const provider = asProvider(opts.provider); const providerCopy = PROVIDER_COPY[provider]; const sourceChoices = schema.field_mappings?.sources || {}; const mappings = useMemo(() => parseMappings(opts.field_mappings), [opts.field_mappings]); const providerMappings = useMemo(() => mappings.filter((row) => row.provider === provider), [mappings, provider]); const setField = (name: string, value: unknown) => setOpts((prev) => ({ ...prev, [name]: value })); const setMappingsForProvider = (nextRows: MappingRow[]) => setOpts((prev) => { const otherRows = parseMappings(prev.field_mappings).filter((row) => row.provider !== provider); return { ...prev, field_mappings: [...otherRows, ...nextRows] }; }); const updateProviderMapping = (index: number, patch: Partial) => { const next = providerMappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch, provider } : row)); setMappingsForProvider(next); }; const addMapping = (preset?: Partial) => { setMappingsForProvider([ ...providerMappings, { provider, remote_field: preset?.remote_field || '', source: preset?.source || 'first_name', }, ]); }; const removeMapping = (index: number) => { setMappingsForProvider(providerMappings.filter((_, rowIndex) => rowIndex !== index)); }; const hasPreset = (remoteField: string) => providerMappings.some((row) => row.remote_field.trim().toLowerCase() === remoteField.trim().toLowerCase()); 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 renderInputField = (name: (typeof BASE_FIELDS)[number]) => { const def = schema[name]; if (!def) { return null; } const value = opts[name]; const inputClass = 'mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-950'; return ( ); }; const renderToggleField = (name: (typeof TOGGLE_FIELDS)[number]) => { const def = schema[name]; if (!def) { return null; } return ( ); }; const createDefaultRule = async (event_key: string) => { toast.clear(); try { const payload: Record = { name: event_key.replace(/_/g, ' '), description: '', is_active: true, priority: 100, event_key, filters: { course: { mode: 'any', ids: [] as number[] } }, actions: { upsert: true, tags_add: [event_key] }, }; await getSikshyaApi().post(`/pro/email-marketing/rules`, payload); toast.success(__('Created', 'sikshya'), 'Rule created.'); void refetchRules(); setTab('rules'); } catch (err) { toast.error(__('Create failed', 'sikshya'), err instanceof Error ? err.message : 'Could not create rule'); } }; const runToolTest = async () => { toast.clear(); setToolBusy(true); try { const user_id = Number(toolUserId) || 0; const course_id = Number(toolCourseId) || 0; if (!user_id) { throw new Error(__('User ID is required.', 'sikshya')); } await getSikshyaApi().post(`/pro/email-marketing/tools/test`, { user_id, course_id, event_key: 'sikshya_user_enrolled', }); toast.success(__('Queued', 'sikshya'), 'Test queued (if consent + rule match).'); setTab('logs'); void refetchLogs(); } catch (err) { toast.error(__('Tool failed', 'sikshya'), err instanceof Error ? err.message : 'Tool failed'); } finally { setToolBusy(false); } }; const runToolBackfill = async () => { toast.clear(); setToolBusy(true); try { const course_id = Number(toolCourseId) || 0; const res = await getSikshyaApi().post<{ ok?: boolean; queued?: number }>(`/pro/email-marketing/tools/backfill`, { course_id, max: 200, days: 365, }); toast.success(__('Queued', 'sikshya'), `Backfill queued ${res?.queued ?? 0} enrollments.`); setTab('logs'); void refetchLogs(); } catch (err) { toast.error(__('Backfill failed', 'sikshya'), err instanceof Error ? err.message : 'Backfill failed'); } finally { setToolBusy(false); } }; return ( addon.enable()} addonError={addon.error} >
setTab('setup')}> Setup setTab('rules')}> Rules setTab('logs')}> Logs setTab('tools')}> Tools
{tab === 'setup' ? ( <> {error ? refetch()} /> : null}

{__('Beginner-friendly setup', 'sikshya')}

Choose the provider your team already uses, paste the two values it asks for, enable sync, then add field mappings only if you need extra data.

{PROVIDER_ORDER.map((id) => { const info = PROVIDER_COPY[id]; const active = provider === id; return ( ); })}
{__('Recommended flow', 'sikshya')}
  1. 1. Pick one provider only.
  2. 2. Paste the API key and destination ID.
  3. 3. Turn on enrollment sync first.
  4. 4. Save, test with one learner, then add mappings.

{providerCopy.beginnerTip}

{loading ? ( ) : (

1. Connect {providerCopy.name}

Paste the credentials and the exact audience, group, list, or form destination where learners should be added.

{providerCopy.listLabel}-based sync
{BASE_FIELDS.map((name) => renderInputField(name))}

2. Choose when to sync

Keep this simple at first. Most noob users start with enrollment sync and only add completion sync later.

{TOGGLE_FIELDS.map((name) => renderToggleField(name))}

3. {schema.field_mappings?.label || 'List field mappings'}

Map Sikshya fields into your {providerCopy.listLabel.toLowerCase()} {providerCopy.mappingNoun}. Skip this until after your first successful sync if you want the easiest setup.

addMapping()}>{__('Add mapping', 'sikshya')}

{providerCopy.name} {providerCopy.mappingNoun}

{providerCopy.fieldHint}

{providerCopy.presets.map((preset) => ( ))}
{providerMappings.length ? ( providerMappings.map((row, index) => (
)) ) : (
No {providerCopy.mappingNoun} mapped yet. Add mappings only if your destination expects extra fields like course name, event, or learner names.
)}
{saving ? __('Saving…', 'sikshya') : __('4. Save settings', 'sikshya')}
)}

{__('Next steps', 'sikshya')}

  • Mailchimp: find your Audience ID in Audience {'->'} Settings {'->'} Audience name and defaults.
  • MailerLite: find your Group ID in Subscribers {'->'} Groups.
  • Brevo: use the numeric List ID and create any mapped contact attributes in Brevo first.
  • Kit: use a Form ID and remember Kit ignores custom fields that do not already exist.
  • After saving, enroll one test learner and confirm the record lands in the right destination before adding more mappings.
) : null} {tab === 'rules' ? ( {rulesError ? refetchRules()} /> : null}

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

Define what to do when events happen. Start with one enrollment rule, confirm it works, then add completion or order rules.

void createDefaultRule('sikshya_user_enrolled')}> + Enrollment rule void createDefaultRule('sikshya_course_completed')}> + Completion rule void createDefaultRule('sikshya_order_fulfilled')}> + Order fulfilled rule
{rulesLoading ? (
) : rules.length ? (
    {rules.map((r) => { const on = Boolean(r.is_active); return (
  • {r.name || r.event_key} {on ? __('Active', 'sikshya') : __('Off', 'sikshya')} {r.event_key}
    {r.description ?

    {r.description}

    : null}

    Priority: {r.priority}

    { const next = { ...r, is_active: !on }; void (async () => { try { await getSikshyaApi().patch(`/pro/email-marketing/rules/${encodeURIComponent(String(r.id))}`, { is_active: !on, name: next.name, description: next.description, priority: next.priority, event_key: next.event_key, filters_json: next.filters_json ?? null, actions_json: next.actions_json ?? null, }); void refetchRules(); } catch (err) { toast.error(__('Update failed', 'sikshya'), err instanceof Error ? err.message : 'Update failed'); } })(); }} > {on ? __('Disable', 'sikshya') : __('Enable', 'sikshya')} { void (async () => { const ok = await dialog.confirm({ title: __('Delete this rule?', 'sikshya'), message: __('This automation rule will be permanently removed.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().delete(`/pro/email-marketing/rules/${encodeURIComponent(String(r.id))}`); void refetchRules(); } catch (err) { toast.error(__('Delete failed', 'sikshya'), err instanceof Error ? err.message : 'Delete failed'); } })(); }} > Delete
  • ); })}
) : (
No rules yet. Add an enrollment rule to get started.
)}
) : null} {tab === 'logs' ? ( {logsError ? refetchLogs()} /> : null}

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

Delivery history for provider calls. This becomes more useful once the queue runner is enabled.

refetchLogs()}> Refresh
{logsLoading ? (
) : logs.length ? (
{logs.map((l) => ( ))}
{__('When', 'sikshya')} {__('Event', 'sikshya')} {__('Provider', 'sikshya')} {__('Status', 'sikshya')} {__('HTTP', 'sikshya')} {__('User', 'sikshya')} {__('Course', 'sikshya')}
{l.created_at || ''} {l.event_key} {l.provider} {l.status} {l.error_message ?
{l.error_message}
: null}
{l.http_code || ''} {l.user_id} {l.course_id}
) : (
No logs yet.
)}
) : null} {tab === 'tools' ? (

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

Test sync and bulk operations. These respect consent and per-course overrides.

{__('Test sync (safe)', 'sikshya')}

Queues a test run for one user (and optional course). If there is no matching rule or the user is not opted-in, nothing is sent.

void runToolTest()}> {toolBusy ? __('Working…', 'sikshya') : __('Queue test', 'sikshya')}
{__('Backfill enrollments', 'sikshya')}

Queues sync jobs for recent enrollments. Use after you create your first enrollment rule.

void runToolBackfill()}> {toolBusy ? __('Working…', 'sikshya') : __('Queue backfill (last 365 days)', 'sikshya')}
) : null}
); }