import type { Dispatch, ReactNode, SetStateAction } from 'react';
import type { SettingsField } from '../types/settingsSchema';
import { DynamicFieldsBuilder } from '../components/settings/DynamicFieldsBuilder';
import { PERMALINK_SETTINGS_PREVIEW_KEYS, previewUrlForPermalinkField } from '../lib/permalinks';
import { __ } from '../lib/i18n';
export function fieldToStringValue(v: unknown): string {
if (v === null || v === undefined) return '';
return String(v);
}
export function isTruthyCheckboxValue(v: unknown): boolean {
return v === true || v === 1 || v === '1' || v === 'yes' || v === 'on';
}
function renderDescription(desc: string): ReactNode {
const raw = String(desc || '').trim();
if (!raw) return null;
// Allow only {__('text', 'sikshya')} from server-provided schema descriptions.
// Everything else is rendered as plain text (with URL linkify).
if (raw.includes('${raw}`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return raw;
const out: ReactNode[] = [];
const walk = (node: ChildNode) => {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent || '';
if (t) out.push(t);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
if (el.tagName.toLowerCase() === 'a') {
const href = String(el.getAttribute('href') || '').trim();
const text = String(el.textContent || href || '').trim();
if (!href || !/^https?:\/\//i.test(href)) {
if (text) out.push(text);
return;
}
out.push(
{text || href}
);
return;
}
// Any other element: render its text content only.
const t = el.textContent || '';
if (t) out.push(t);
};
root.childNodes.forEach((n) => walk(n));
return <>{out}>;
} catch {
// Fall through to linkify.
}
}
// Linkify plain URLs.
const parts = raw.split(/(https?:\/\/[^\s)]+)\b/g);
if (parts.length <= 1) return raw;
return (
<>
{parts.map((p, idx) => {
const isUrl = /^https?:\/\//i.test(p);
if (!isUrl) return {p} ;
return (
{p}
);
})}
>
);
}
/**
* Wrap a locked field in a disabled overlay with a "Pro" pill and a friendly
* reason. Shared between the Settings page, the Email page, and any consumer
* that uses `renderSettingsField`.
*/
function LockedFieldOverlay(props: { f: SettingsField; children: ReactNode; className?: string }) {
const { f, children, className = '' } = props;
const reason = f.locked_reason || 'Available on a higher Sikshya Pro plan.';
const addonLabel = f.required_addon_label || f.required_addon || '';
const planLabel = f.required_plan_label || '';
return (
{children}
★ Pro{planLabel ? ` • ${planLabel}` : ''}
{addonLabel ? (
<>
{__('Addon:', 'sikshya')} {addonLabel} •
>
) : null}
{reason}
);
}
/**
* Shared field renderer for Settings and the dedicated Email admin page.
*/
export function renderSettingsField(
draft: Record,
setDraft: Dispatch>>,
f: SettingsField
) {
const k = f.key;
const type = f.type || 'text';
const cur = draft[k];
const label = f.label || k;
const desc = f.description || '';
const locked = !!f.locked;
// When a field is Pro-locked we render read-only controls so the user can
// still see the (default) shape of the field but cannot modify it.
const readOnly = locked;
const onChangeGuard = (cb: (v: T) => void) => (v: T) => {
if (readOnly) return;
cb(v);
};
let body: ReactNode;
if (type === 'dynamic_fields_builder') {
body = (
{label}
{desc ? (
{renderDescription(desc)}
) : null}
((v) => setDraft((p) => ({ ...p, [k]: v })))}
/>
);
} else if (type === 'checkbox') {
const checked = isTruthyCheckboxValue(cur);
body = (
onChangeGuard((v) => setDraft((p) => ({ ...p, [k]: v ? '1' : '0' })))(e.target.checked)}
/>
{label}
{desc ? (
{renderDescription(desc)}
) : null}
);
} else if (type === 'select') {
const opts = f.options || {};
const optKeys = Object.keys(opts).map((x) => String(x));
const ph = f.select_placeholder;
const raw = fieldToStringValue(cur ?? f.default ?? '');
const selectValue = ph && (raw === '' || !optKeys.includes(raw)) ? '' : raw;
body = (
{label}
{desc ? (
{renderDescription(desc)}
) : null}
onChangeGuard((v) => setDraft((p) => ({ ...p, [k]: v })))(e.target.value)}
>
{ph ? {ph} : null}
{Object.entries(opts).map(([ov, ol]) => (
{ol}
))}
);
} else if (type === 'multi_select') {
const opts = f.options || {};
const allowed = Object.keys(opts).map((x) => String(x));
const raw = fieldToStringValue(cur ?? f.default ?? '');
const selected = raw
.split(',')
.map((x) => String(x || '').trim())
.filter((x) => x !== '')
.filter((x, i, a) => a.indexOf(x) === i)
.filter((x) => allowed.includes(x));
const setSelected = (vals: string[]) => {
const normalized = vals
.map((x) => String(x || '').trim())
.filter((x) => x !== '' && allowed.includes(x))
.filter((x, i, a) => a.indexOf(x) === i);
setDraft((p) => ({ ...p, [k]: normalized.join(',') }));
};
body = (
{label}
{desc ? (
{renderDescription(desc)}
) : null}
onChangeGuard((v) => setSelected(v))(
Array.from(e.target.selectedOptions || []).map((o) => String(o.value))
)
}
size={Math.min(6, Math.max(3, allowed.length))}
>
{Object.entries(opts).map(([ov, ol]) => (
{ol}
))}
{selected.length ? (
{selected.map((v) => (
{opts[v] || v}
{!readOnly ? (
setSelected(selected.filter((x) => x !== v))}
aria-label={`Remove ${opts[v] || v}`}
>
×
) : null}
))}
) : (
Select one or more methods. The value is stored as a comma-separated list.
)}
);
} else if (type === 'color') {
const hex = (() => {
const s = fieldToStringValue(cur ?? f.default ?? '#000000');
return /^#[0-9A-Fa-f]{6}$/.test(s) ? s : '#000000';
})();
body = (
{label}
{desc ? (
{renderDescription(desc)}
) : null}
onChangeGuard((v) => setDraft((p) => ({ ...p, [k]: v })))(e.target.value)}
aria-label={label}
/>
{hex}
);
} else if (type === 'textarea') {
body = (
{label}
{desc ? (
{renderDescription(desc)}
) : null}
);
} else {
const inputType =
type === 'number'
? 'number'
: type === 'email'
? 'email'
: type === 'password'
? 'password'
: type === 'url'
? 'url'
: type === 'datetime-local'
? 'datetime-local'
: 'text';
const permalinkPreview =
inputType === 'text' && PERMALINK_SETTINGS_PREVIEW_KEYS.has(k) ? previewUrlForPermalinkField(k, draft) : null;
body = (
{label}
{permalinkPreview ? (
Open
) : null}
{desc ? (
{renderDescription(desc)}
) : null}
onChangeGuard
((v) => setDraft((p) => ({ ...p, [k]: v })))(e.target.value)}
placeholder={f.placeholder || ''}
min={typeof f.min === 'number' ? f.min : undefined}
max={typeof f.max === 'number' ? f.max : undefined}
step={typeof f.step === 'number' ? f.step : type === 'number' ? 1 : undefined}
autoComplete={type === 'password' ? 'new-password' : type === 'email' ? 'email' : undefined}
/>
);
}
// Checkbox and textarea span two columns; keep that behavior on the wrapper
// so the lock-overlay doesn't break the grid layout.
const spanClass = type === 'checkbox' || type === 'textarea' || type === 'dynamic_fields_builder' ? 'lg:col-span-2' : '';
if (locked) {
return (
{body}
);
}
return (
{body}
);
}