/** * RulesPanel — personalization rules CRUD. * * Backend already enforces DSL validation (see * personalize/rules.py::validate_rule). We mirror the allowed-key * list here so the form can surface local hints, but the final * word on validity belongs to the server — on 400 we render the * server's `data.problems` array inline. * * Rules are compact JSON blobs. The form exposes: * - name (free text) * - priority (int; lower = higher priority) * - enabled (toggle) * - condition + action (JSON textareas with live parse errors) * * Advanced users can edit the JSON directly; typical users see * a hint listing the allowed keys so they don't have to read the * backend source. */ import React, { useCallback, useMemo, useState } from "react"; import { Sparkles, Trash2 } from "lucide-react"; import type { InteractiveApi } from "./api"; import { InteractiveApiError } from "./types"; import type { RuleItem } from "./types"; import { DangerButton, EmptyState, ErrorBanner, Modal, Panel, PrimaryButton, SecondaryButton, SkeletonRow, useAsyncResource, useToast, } from "./ui"; export interface RulesPanelProps { api: InteractiveApi; projectId: string; } const ALLOWED_CONDITION_KEYS = [ "role", "level", "language", "country", "has_tag", "mood", "min_affinity", "max_affinity", "metric", ]; const ALLOWED_ACTION_KEYS = [ "route_to_node", "prefer_tone", "bump_affinity", ]; export function RulesPanel({ api, projectId }: RulesPanelProps) { const toast = useToast(); const resource = useAsyncResource( (signal) => api.listRules(projectId, signal), [api, projectId], ); const [createOpen, setCreateOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); const items = useMemo(() => resource.data || [], [resource.data]); const onCreated = useCallback((created: RuleItem) => { resource.setData((prev) => { const next = [...(prev || []), created]; next.sort((a, b) => a.priority - b.priority); return next; }); }, [resource]); const onDelete = useCallback(async () => { const target = confirmDelete; if (!target) return; const snapshot = items; resource.setData((prev) => (prev || []).filter((r) => r.id !== target.id)); setConfirmDelete(null); try { await api.deleteRule(target.id); toast.toast({ variant: "success", title: "Rule deleted" }); } catch (err) { const e = err as InteractiveApiError; resource.setData(snapshot); toast.toast({ variant: "error", title: "Couldn't delete rule", message: e.message || "The rule has been restored.", }); } }, [api, confirmDelete, items, resource, toast]); return ( setCreateOpen(true)} size="sm" icon={} > New rule } > {resource.error ? ( ) : resource.loading && !resource.data ? (
{Array.from({ length: 3 }).map((_, i) => )}
) : items.length === 0 ? ( } title="No personalization rules" description="Rules let you route viewers based on mood, affinity, or audience facets. Great for adaptive paths without coding." action={ setCreateOpen(true)} icon={}> Add your first rule } /> ) : (
    {items.map((r) => ( setConfirmDelete(r)} /> ))}
)} setCreateOpen(false)} api={api} projectId={projectId} onCreated={(r) => { onCreated(r); setCreateOpen(false); toast.toast({ variant: "success", title: "Rule created", message: r.name }); }} /> setConfirmDelete(null)} title="Delete rule?" footer={ <> setConfirmDelete(null)}>Cancel }> Delete } >

This permanently removes {confirmDelete?.name}.

); } // ──────────────────────────────────────────────────────────────── // Rule row // ──────────────────────────────────────────────────────────────── function RuleRow({ rule, onDelete }: { rule: RuleItem; onDelete: () => void }) { const condKeys = Object.keys(rule.condition || {}); const actionKeys = Object.keys(rule.action || {}); return (
  • {rule.name} priority {rule.priority} {!rule.enabled && ( disabled )}
    {condKeys.length > 0 ? condKeys.map((k) => ( when {k} )) : no conditions} {actionKeys.map((k) => ( → {k} ))}
  • ); } // ──────────────────────────────────────────────────────────────── // New-rule modal // ──────────────────────────────────────────────────────────────── function NewRuleModal({ open, onClose, api, projectId, onCreated, }: { open: boolean; onClose: () => void; api: InteractiveApi; projectId: string; onCreated: (r: RuleItem) => void; }) { const toast = useToast(); const [name, setName] = useState(""); const [priority, setPriority] = useState(100); const [enabled, setEnabled] = useState(true); const [conditionText, setConditionText] = useState('{\n "mood": "flirty"\n}'); const [actionText, setActionText] = useState('{\n "bump_affinity": 0.05\n}'); const [submitting, setSubmitting] = useState(false); const [serverProblems, setServerProblems] = useState(null); const conditionErr = useMemo(() => parseJsonError(conditionText), [conditionText]); const actionErr = useMemo(() => parseJsonError(actionText), [actionText]); const reset = useCallback(() => { setName(""); setPriority(100); setEnabled(true); setConditionText('{\n "mood": "flirty"\n}'); setActionText('{\n "bump_affinity": 0.05\n}'); setServerProblems(null); }, []); const canSubmit = name.trim().length > 0 && !conditionErr && !actionErr && !submitting; const submit = useCallback(async () => { if (!canSubmit) return; setSubmitting(true); setServerProblems(null); try { const condition = JSON.parse(conditionText) as Record; const action = JSON.parse(actionText) as Record; const created = await api.createRule(projectId, { name: name.trim(), condition, action, priority, enabled, }); reset(); onCreated(created); } catch (err) { const e = err as InteractiveApiError; const problems = Array.isArray(e.data?.problems) ? (e.data.problems as string[]) : null; if (problems && problems.length > 0) { setServerProblems(problems); } else { toast.toast({ variant: "error", title: "Couldn't create rule", message: e.message || "Check the JSON and try again.", }); } setSubmitting(false); } }, [actionText, api, canSubmit, conditionText, enabled, name, onCreated, priority, projectId, reset, toast]); return ( { if (!submitting) { reset(); onClose(); } }} title="New personalization rule" widthClass="max-w-2xl" footer={ <> { reset(); onClose(); }} disabled={submitting}> Cancel Create } >
    setName(e.target.value)} placeholder="e.g. Warm up when the viewer is flirty" maxLength={80} className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]" />
    setPriority(Math.max(1, parseInt(e.target.value, 10) || 1))} className="w-full bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2 text-sm outline-none focus:border-[#3ea6ff]" />