i18n: Localize AutomationBuilder component and fix typing

This commit is contained in:
Ivan Mercedes 2026-07-07 17:51:09 -04:00
parent 92e5789837
commit d518d54520
5 changed files with 377 additions and 159 deletions

View File

@ -739,5 +739,182 @@
"toastFailedPreview": "Failed to load preview data" "toastFailedPreview": "Failed to load preview data"
} }
} }
},
"Automations": {
"list": {
"title": "Automations",
"subtitle": "Build workflows that react to WhatsApp® events automatically.",
"create": "Create Automation",
"retry": "Retry",
"templatesTitle": "Quick-start templates",
"emptyTitle": "No automations yet",
"emptyDesc": "Pick a template above or create one from scratch.",
"runs": "{count} run",
"runsPlural": "{count} runs",
"lastRun": "last {time}",
"activate": "Activate",
"deactivate": "Deactivate",
"edit": "Edit",
"duplicate": "Duplicate",
"viewLogs": "View Logs",
"delete": "Delete",
"cancel": "Cancel",
"deleteTitle": "Delete automation",
"deleteDesc": "This permanently removes {name} and its execution history. This cannot be undone.",
"toasts": {
"activated": "Automation activated",
"paused": "Automation paused",
"duplicated": "Automation duplicated",
"deleted": "Automation deleted",
"updateError": "Failed to update",
"duplicateError": "Failed to duplicate",
"deleteError": "Failed to delete"
}
},
"edit": {
"loading": "Loading...",
"loadError": "Failed to load ({status})",
"back": "Back to Automations"
},
"logs": {
"title": "Execution logs",
"loadError": "Failed to load logs",
"emptyTitle": "No executions yet",
"emptyDesc": "Trigger this automation to see runs here.",
"unknownContact": "Unknown contact",
"step": "{count} step",
"stepPlural": "{count} steps",
"noSteps": "No steps recorded.",
"status": {
"success": "success",
"partial": "partial",
"failed": "failed"
},
"back": "Back",
"backAria": "Back"
},
"builder": {
"steps": {
"send_message": "Send Message",
"send_template": "Send Template",
"add_tag": "Add Tag",
"remove_tag": "Remove Tag",
"assign_conversation": "Assign Conversation",
"update_contact_field": "Update Contact Field",
"create_deal": "Create Deal",
"wait": "Wait",
"condition": "Condition (If/Else)",
"send_webhook": "Send Webhook",
"close_conversation": "Close Conversation"
},
"triggers": {
"new_message_received": { "label": "New Message Received", "hint": "Any incoming message" },
"first_inbound_message": { "label": "First Message from Contact", "hint": "First time this contact ever messages you (works for manually-added contacts too)" },
"keyword_match": { "label": "Keyword Match", "hint": "Message contains specific keyword(s)" },
"new_contact_created": { "label": "New Contact Created", "hint": "When a contact is auto-created from an incoming message" },
"conversation_assigned": { "label": "Conversation Assigned", "hint": "When assigned to an agent" },
"tag_added": { "label": "Tag Added", "hint": "When a tag is added to a contact" },
"time_based": { "label": "Time-Based", "hint": "On a recurring schedule" }
},
"trigger": "Trigger",
"triggerType": "Trigger type",
"keywords": "Keywords",
"keywordsHint": "Comma-separated list (e.g. hello, help, buy)",
"schedule": "Schedule",
"scheduleHint": "Cron expression (e.g. 0 9 * * 1-5)",
"active": "Active",
"activeAria": "Active",
"save": "Save",
"saveDraft": "Save Draft",
"untitled": "Untitled automation",
"backToAutomations": "Back to automations",
"toasts": {
"saved": "Automation saved",
"created": "Automation created",
"saveFailed": "Save failed"
},
"tags": {
"select": "Select a tag…",
"placeholder": "Tag id",
"unknown": "{id} (unknown tag)"
},
"fields": {
"name": "Name",
"email": "Email",
"company": "Company",
"customFields": "Custom fields",
"unknown": "{id} (unknown field)"
},
"agents": {
"select": "Select an agent…",
"placeholder": "Agent id",
"unknown": "{id} (unknown agent)"
},
"pipelines": {
"pipelineLabel": "Pipeline",
"pipelineIdLabel": "Pipeline id",
"stageLabel": "Stage",
"stageIdLabel": "Stage id",
"selectPipeline": "Select a pipeline…",
"selectStage": "Select a stage…",
"selectPipelineFirst": "Select a pipeline first…",
"unknownPipeline": "{id} (unknown pipeline)",
"unknownStage": "{id} (unknown stage)"
},
"templates": {
"templateLabel": "Template",
"templateNameLabel": "Template name",
"languageLabel": "Language",
"select": "Select a template…",
"unknown": "{name} ({lang}) — not in approved list",
"unknownLang": "unknown"
},
"branches": {
"yes": "Yes",
"no": "No"
},
"addStep": "Add step",
"config": {
"messageText": "Message text",
"placeholderMessageText": "Hi! Thanks for reaching out…",
"tagLabel": "Tag",
"modeLabel": "Mode",
"modes": {
"round_robin": "Round-robin",
"specific": "Specific agent"
},
"agentLabel": "Agent",
"fieldLabel": "Field",
"valueLabel": "Value",
"placeholderValue": "Text or {{ vars.x }} / {{ message.text }}",
"titleLabel": "Title",
"amountLabel": "Amount",
"unitLabel": "Unit",
"units": {
"minutes": "Minutes",
"hours": "Hours",
"days": "Days"
},
"subjectLabel": "Subject",
"subjects": {
"tag_presence": "Tag presence",
"contact_field": "Contact field",
"message_content": "Message content",
"time_of_day": "Time of day"
},
"operandLabel": "Operand",
"placeholderTime": "HH:mm-HH:mm",
"placeholderContact": "name / email / company",
"placeholderTag": "tag id",
"urlLabel": "URL",
"headersLabel": "Headers",
"placeholderHeaders": "{\"Authorization\": \"Bearer ...\"}",
"bodyTemplateLabel": "Body template",
"placeholderBody": "{\"id\": \"{{ contact.id }}\"}",
"matchType": "Match type",
"matchContains": "Contains",
"matchExact": "Exact"
}
}
} }
} }

View File

@ -3,6 +3,7 @@
import { use, useEffect, useState } from "react" import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { import {
AutomationBuilder, AutomationBuilder,
@ -19,6 +20,7 @@ export default function EditAutomationPage({
}) { }) {
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.edit")
const [initial, setInitial] = useState<BuilderInitial | null>(null) const [initial, setInitial] = useState<BuilderInitial | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -27,7 +29,7 @@ export default function EditAutomationPage({
async function load() { async function load() {
const res = await fetch(`/api/automations/${id}`) const res = await fetch(`/api/automations/${id}`)
if (!res.ok) { if (!res.ok) {
if (!cancelled) setError(`Failed to load (${res.status})`) if (!cancelled) setError(t("loadError", { status: res.status }))
return return
} }
const body = await res.json() const body = await res.json()
@ -56,7 +58,7 @@ export default function EditAutomationPage({
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="text-sm text-primary hover:text-primary/80" className="text-sm text-primary hover:text-primary/80"
> >
Back to Automations {t("back")}
</button> </button>
</div> </div>
) )

View File

@ -10,6 +10,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
} from "lucide-react" } from "lucide-react"
import { useTranslations } from "next-intl"
import { createClient } from "@/lib/supabase/client" import { createClient } from "@/lib/supabase/client"
import type { import type {
@ -28,6 +29,7 @@ export default function AutomationLogsPage({
}) { }) {
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.logs")
const [automation, setAutomation] = useState<Automation | null>(null) const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null) const [logs, setLogs] = useState<AutomationLog[] | null>(null)
@ -56,7 +58,7 @@ export default function AutomationLogsPage({
setAutomation(autRes.data as Automation | null) setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[]) setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs") setError(err instanceof Error ? err.message : t("loadError"))
} }
} }
load() load()
@ -67,7 +69,7 @@ export default function AutomationLogsPage({
<div className="flex h-64 flex-col items-center justify-center gap-3"> <div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}> <Button variant="outline" onClick={() => router.push("/automations")}>
Back {t("back")}
</Button> </Button>
</div> </div>
) )
@ -88,21 +90,21 @@ export default function AutomationLogsPage({
type="button" type="button"
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back" aria-label={t("backAria")}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </button>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1> <h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p> <p className="mt-0.5 text-sm text-muted-foreground">{t("title")}</p>
</div> </div>
</div> </div>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40"> <div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<p className="text-sm text-foreground">No executions yet</p> <p className="text-sm text-foreground">{t("emptyTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Trigger this automation to see runs here. {t("emptyDesc")}
</p> </p>
</div> </div>
) : ( ) : (
@ -124,14 +126,14 @@ export default function AutomationLogsPage({
) : ( ) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
<StatusBadge status={log.status} /> <StatusBadge status={log.status} t={t} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground"> <div className="truncate text-sm font-medium text-foreground">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"} {log.contact?.name ?? log.contact?.phone ?? t("unknownContact")}
</div> </div>
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step {log.trigger_event} · {log.steps_executed?.length ?? 0}{" "}
{log.steps_executed?.length === 1 ? "" : "s"} {log.steps_executed?.length === 1 ? t("step", { count: 1 }).replace("1 ", "") : t("stepPlural", { count: log.steps_executed?.length ?? 0 }).replace(/^[0-9]+ /, "")}
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@ -150,7 +152,7 @@ export default function AutomationLogsPage({
<StepRow key={i} result={r} /> <StepRow key={i} result={r} />
))} ))}
{(log.steps_executed ?? []).length === 0 && ( {(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-muted-foreground">No steps recorded.</li> <li className="text-xs text-muted-foreground">{t("noSteps")}</li>
)} )}
</ul> </ul>
</div> </div>
@ -164,7 +166,7 @@ export default function AutomationLogsPage({
) )
} }
function StatusBadge({ status }: { status: AutomationLog["status"] }) { function StatusBadge({ status, t }: { status: AutomationLog["status"], t: ReturnType<typeof useTranslations> }) {
const classes = const classes =
status === "success" status === "success"
? "border-primary/30 bg-primary/10 text-primary" ? "border-primary/30 bg-primary/10 text-primary"
@ -178,7 +180,7 @@ function StatusBadge({ status }: { status: AutomationLog["status"] }) {
classes, classes,
)} )}
> >
{status} {t(`status.${status}`)}
</span> </span>
) )
} }

View File

@ -20,6 +20,7 @@ import {
import { createClient } from "@/lib/supabase/client" import { createClient } from "@/lib/supabase/client"
import { useCan } from "@/hooks/use-can" import { useCan } from "@/hooks/use-can"
import { useTranslations } from "next-intl"
import type { Automation } from "@/types" import type { Automation } from "@/types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { GatedButton } from "@/components/ui/gated-button" import { GatedButton } from "@/components/ui/gated-button"
@ -60,6 +61,7 @@ const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
export default function AutomationsPage() { export default function AutomationsPage() {
const router = useRouter() const router = useRouter()
const canCreate = useCan("send-messages") const canCreate = useCan("send-messages")
const t = useTranslations("Automations.list")
const [automations, setAutomations] = useState<Automation[] | null>(null) const [automations, setAutomations] = useState<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null) const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
@ -99,20 +101,20 @@ export default function AutomationsPage() {
prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev, prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev,
) )
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to update") toast.error(body?.error ?? t("toasts.updateError"))
return return
} }
toast.success(next ? "Automation activated" : "Automation paused") toast.success(next ? t("toasts.activated") : t("toasts.paused"))
} }
async function duplicate(a: Automation) { async function duplicate(a: Automation) {
const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" }) const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" })
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to duplicate") toast.error(body?.error ?? t("toasts.duplicateError"))
return return
} }
toast.success("Automation duplicated") toast.success(t("toasts.duplicated"))
load() load()
} }
@ -123,10 +125,10 @@ export default function AutomationsPage() {
setDeleting(false) setDeleting(false)
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to delete") toast.error(body?.error ?? t("toasts.deleteError"))
return return
} }
toast.success("Automation deleted") toast.success(t("toasts.deleted"))
setPendingDelete(null) setPendingDelete(null)
load() load()
} }
@ -140,7 +142,7 @@ export default function AutomationsPage() {
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
Retry {t("retry")}
</Button> </Button>
</div> </div>
) )
@ -160,9 +162,9 @@ export default function AutomationsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Automations</h1> <h1 className="text-2xl font-bold text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Build workflows that react to WhatsApp® events automatically. {t("subtitle")}
</p> </p>
</div> </div>
<GatedButton <GatedButton
@ -172,13 +174,13 @@ export default function AutomationsPage() {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Automation {t("create")}
</GatedButton> </GatedButton>
</div> </div>
{showTemplates && ( {showTemplates && (
<section> <section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2> <h2 className="mb-3 text-sm font-semibold text-muted-foreground">{t("templatesTitle")}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => { {TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug] const t = AUTOMATION_TEMPLATES[slug]
@ -206,9 +208,9 @@ export default function AutomationsPage() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Zap className="h-6 w-6 text-primary" /> <Zap className="h-6 w-6 text-primary" />
</div> </div>
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p> <p className="mt-3 text-sm font-medium text-foreground">{t("emptyTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Pick a template above or create one from scratch. {t("emptyDesc")}
</p> </p>
</div> </div>
) : ( ) : (
@ -222,6 +224,7 @@ export default function AutomationsPage() {
onDuplicate={() => duplicate(a)} onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)} onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)} onDelete={() => setPendingDelete(a)}
t={t}
/> />
))} ))}
</ul> </ul>
@ -230,11 +233,9 @@ export default function AutomationsPage() {
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}> <Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete automation</DialogTitle> <DialogTitle>{t("deleteTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
This permanently removes{" "} {t("deleteDesc", { name: pendingDelete?.name ?? "" })}
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -243,7 +244,7 @@ export default function AutomationsPage() {
onClick={() => setPendingDelete(null)} onClick={() => setPendingDelete(null)}
disabled={deleting} disabled={deleting}
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -251,7 +252,7 @@ export default function AutomationsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete {t("delete")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -267,6 +268,7 @@ function AutomationCard({
onDuplicate, onDuplicate,
onLogs, onLogs,
onDelete, onDelete,
t,
}: { }: {
automation: Automation automation: Automation
onToggle: (next: boolean) => void onToggle: (next: boolean) => void
@ -274,6 +276,7 @@ function AutomationCard({
onDuplicate: () => void onDuplicate: () => void
onLogs: () => void onLogs: () => void
onDelete: () => void onDelete: () => void
t: ReturnType<typeof useTranslations>
}) { }) {
const meta = triggerMeta(automation.trigger_type) const meta = triggerMeta(automation.trigger_type)
return ( return (
@ -315,10 +318,12 @@ function AutomationCard({
{meta.label} {meta.label}
</span> </span>
<span className="tabular-nums"> <span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"} {automation.execution_count === 1
? t("runs", { count: automation.execution_count })
: t("runsPlural", { count: automation.execution_count })}
</span> </span>
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span> <span>{t("lastRun", { time: formatRelative(automation.last_executed_at) })}</span>
</div> </div>
</button> </button>
@ -326,7 +331,7 @@ function AutomationCard({
<Switch <Switch
checked={automation.is_active} checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)} onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"} aria-label={automation.is_active ? t("deactivate") : t("activate")}
/> />
<DropdownMenu> <DropdownMenu>
@ -339,20 +344,20 @@ function AutomationCard({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}> <DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
Edit {t("edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}> <DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Duplicate {t("duplicate")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}> <DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
View Logs {t("viewLogs")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete {t("delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -8,6 +8,7 @@ import {
type ReactNode, type ReactNode,
} from "react" } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ArrowLeft, ArrowLeft,
@ -88,17 +89,17 @@ interface StepMeta {
} }
const STEP_META: Record<AutomationStepType, StepMeta> = { const STEP_META: Record<AutomationStepType, StepMeta> = {
send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-primary" }, send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" },
send_template: { label: "Send Template", icon: FileText, border: "border-l-primary" }, send_template: { label: "send_template", icon: FileText, border: "border-l-primary" },
add_tag: { label: "Add Tag", icon: Tag, border: "border-l-primary" }, add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" },
remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-primary" }, remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" },
assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" }, assign_conversation: { label: "assign_conversation", icon: UserCheck, border: "border-l-primary" },
update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" }, update_contact_field: { label: "update_contact_field", icon: PencilLine, border: "border-l-primary" },
create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" }, create_deal: { label: "create_deal", icon: Briefcase, border: "border-l-primary" },
wait: { label: "Wait", icon: Hourglass, border: "border-l-border" }, wait: { label: "wait", icon: Hourglass, border: "border-l-border" },
condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, condition: { label: "condition", icon: GitBranch, border: "border-l-amber-500" },
send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" }, send_webhook: { label: "send_webhook", icon: Webhook, border: "border-l-primary" },
close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" }, close_conversation: { label: "close_conversation", icon: CircleSlash, border: "border-l-primary" },
} }
const ADDABLE_STEPS: AutomationStepType[] = [ const ADDABLE_STEPS: AutomationStepType[] = [
@ -115,18 +116,14 @@ const ADDABLE_STEPS: AutomationStepType[] = [
"close_conversation", "close_conversation",
] ]
const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [
{ value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, { value: "new_message_received" },
{ { value: "first_inbound_message" },
value: "first_inbound_message", { value: "keyword_match" },
label: "First Message from Contact", { value: "new_contact_created" },
hint: "First time this contact ever messages you (works for manually-added contacts too)", { value: "conversation_assigned" },
}, { value: "tag_added" },
{ value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, { value: "time_based" },
{ value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created from an incoming message" },
{ value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" },
{ value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" },
{ value: "time_based", label: "Time-Based", hint: "On a recurring schedule" },
] ]
function cid(): string { function cid(): string {
@ -286,15 +283,17 @@ const SELECT_CLASS =
function TagSelect({ function TagSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { tags } = useResources() const { tags } = useResources()
if (tags.length === 0) { if (tags.length === 0) {
return ( return (
<Input <Input
placeholder="Tag id" placeholder={t("tags.placeholder")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground" className="bg-muted text-foreground"
@ -314,16 +313,16 @@ function TagSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a tag…</option> <option value="">{t("tags.select")}</option>
{tags.map((t) => ( {tags.map((tg) => (
<option key={t.id} value={t.id}> <option key={tg.id} value={tg.id}>
{t.name} {tg.name}
</option> </option>
))} ))}
{/* Preserve a saved tag that's since been deleted so editing an {/* Preserve a saved tag that's since been deleted so editing an
existing automation doesn't silently drop it. */} existing automation doesn't silently drop it. */}
{value && !selected && ( {value && !selected && (
<option value={value}>{value} (unknown tag)</option> <option value={value}>{t("tags.unknown", { id: value })}</option>
)} )}
</select> </select>
</div> </div>
@ -337,9 +336,11 @@ function TagSelect({
function ContactFieldSelect({ function ContactFieldSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { customFields } = useResources() const { customFields } = useResources()
const customValue = value.startsWith("custom:") ? value : "" const customValue = value.startsWith("custom:") ? value : ""
@ -351,11 +352,11 @@ function ContactFieldSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="name">Name</option> <option value="name">{t("fields.name")}</option>
<option value="email">Email</option> <option value="email">{t("fields.email")}</option>
<option value="company">Company</option> <option value="company">{t("fields.company")}</option>
{customFields.length > 0 && ( {customFields.length > 0 && (
<optgroup label="Custom fields"> <optgroup label={t("fields.customFields")}>
{customFields.map((f) => ( {customFields.map((f) => (
<option key={f.id} value={`custom:${f.id}`}> <option key={f.id} value={`custom:${f.id}`}>
{f.field_name} {f.field_name}
@ -364,7 +365,7 @@ function ContactFieldSelect({
</optgroup> </optgroup>
)} )}
{customValue && !knownCustom && ( {customValue && !knownCustom && (
<option value={customValue}>{customValue} (unknown field)</option> <option value={customValue}>{t("fields.unknown", { id: customValue })}</option>
)} )}
</select> </select>
) )
@ -375,15 +376,17 @@ function ContactFieldSelect({
function AgentSelect({ function AgentSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { members } = useResources() const { members } = useResources()
if (members.length === 0) { if (members.length === 0) {
return ( return (
<Input <Input
placeholder="Agent id" placeholder={t("agents.placeholder")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground" className="bg-muted text-foreground"
@ -397,14 +400,14 @@ function AgentSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select an agent…</option> <option value="">{t("agents.select")}</option>
{members.map((m) => ( {members.map((m) => (
<option key={m.user_id} value={m.user_id}> <option key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id} {m.full_name || m.email || m.user_id}
</option> </option>
))} ))}
{value && !selected && ( {value && !selected && (
<option value={value}>{value} (unknown agent)</option> <option value={value}>{t("agents.unknown", { id: value })}</option>
)} )}
</select> </select>
) )
@ -416,17 +419,19 @@ function DealPipelineFields({
pipelineId, pipelineId,
stageId, stageId,
onChange, onChange,
t,
}: { }: {
pipelineId: string pipelineId: string
stageId: string stageId: string
onChange: (patch: { pipeline_id: string; stage_id: string }) => void onChange: (patch: { pipeline_id: string; stage_id: string }) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { pipelines, stages } = useResources() const { pipelines, stages } = useResources()
if (pipelines.length === 0) { if (pipelines.length === 0) {
return ( return (
<> <>
<FieldBlock label="Pipeline id"> <FieldBlock label={t("pipelines.pipelineIdLabel")}>
<Input <Input
value={pipelineId} value={pipelineId}
onChange={(e) => onChange={(e) =>
@ -435,7 +440,7 @@ function DealPipelineFields({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Stage id"> <FieldBlock label={t("pipelines.stageIdLabel")}>
<Input <Input
value={stageId} value={stageId}
onChange={(e) => onChange={(e) =>
@ -454,7 +459,7 @@ function DealPipelineFields({
return ( return (
<> <>
<FieldBlock label="Pipeline"> <FieldBlock label={t("pipelines.pipelineLabel")}>
<select <select
value={pipelineId} value={pipelineId}
onChange={(e) => { onChange={(e) => {
@ -469,18 +474,18 @@ function DealPipelineFields({
}} }}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a pipeline…</option> <option value="">{t("pipelines.selectPipeline")}</option>
{pipelines.map((p) => ( {pipelines.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
</option> </option>
))} ))}
{pipelineId && !selectedPipeline && ( {pipelineId && !selectedPipeline && (
<option value={pipelineId}>{pipelineId} (unknown pipeline)</option> <option value={pipelineId}>{t("pipelines.unknownPipeline", { id: pipelineId })}</option>
)} )}
</select> </select>
</FieldBlock> </FieldBlock>
<FieldBlock label="Stage"> <FieldBlock label={t("pipelines.stageLabel")}>
<select <select
value={stageId} value={stageId}
onChange={(e) => onChange={(e) =>
@ -490,7 +495,7 @@ function DealPipelineFields({
disabled={!pipelineId || stageOptions.length === 0} disabled={!pipelineId || stageOptions.length === 0}
> >
<option value=""> <option value="">
{pipelineId ? "Select a stage…" : "Select a pipeline first…"} {pipelineId ? t("pipelines.selectStage") : t("pipelines.selectPipelineFirst")}
</option> </option>
{stageOptions.map((s) => ( {stageOptions.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@ -498,7 +503,7 @@ function DealPipelineFields({
</option> </option>
))} ))}
{stageId && pipelineId && !selectedStage && ( {stageId && pipelineId && !selectedStage && (
<option value={stageId}>{stageId} (unknown stage)</option> <option value={stageId}>{t("pipelines.unknownStage", { id: stageId })}</option>
)} )}
</select> </select>
</FieldBlock> </FieldBlock>
@ -513,17 +518,19 @@ function SendTemplateFields({
templateName, templateName,
language, language,
onChange, onChange,
t,
}: { }: {
templateName: string templateName: string
language: string language: string
onChange: (patch: { template_name: string; language: string }) => void onChange: (patch: { template_name: string; language: string }) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { templates } = useResources() const { templates } = useResources()
if (templates.length === 0) { if (templates.length === 0) {
return ( return (
<> <>
<FieldBlock label="Template name"> <FieldBlock label={t("templates.templateNameLabel")}>
<Input <Input
value={templateName} value={templateName}
onChange={(e) => onChange={(e) =>
@ -532,7 +539,7 @@ function SendTemplateFields({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Language"> <FieldBlock label={t("templates.languageLabel")}>
<Input <Input
value={language} value={language}
onChange={(e) => onChange={(e) =>
@ -554,7 +561,7 @@ function SendTemplateFields({
) )
return ( return (
<FieldBlock label="Template"> <FieldBlock label={t("templates.templateLabel")}>
<select <select
value={current} value={current}
onChange={(e) => { onChange={(e) => {
@ -563,18 +570,18 @@ function SendTemplateFields({
}} }}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a template…</option> <option value="">{t("templates.select")}</option>
{templates.map((t) => { {templates.map((tmpl) => {
const lang = t.language ?? "en_US" const lang = tmpl.language ?? "en_US"
return ( return (
<option key={t.id} value={toValue(t.name, lang)}> <option key={tmpl.id} value={toValue(tmpl.name, lang)}>
{t.name} ({lang}) {tmpl.name} ({lang})
</option> </option>
) )
})} })}
{current && !hasMatch && ( {current && !hasMatch && (
<option value={current}> <option value={current}>
{templateName} ({language || "unknown"}) — not in approved list {t("templates.unknown", { name: templateName, lang: language || t("templates.unknownLang") })}
</option> </option>
)} )}
</select> </select>
@ -588,6 +595,7 @@ function SendTemplateFields({
export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.builder")
const isEditing = !!initial.id const isEditing = !!initial.id
const [state, setState] = useState<BuilderInitial>(initial) const [state, setState] = useState<BuilderInitial>(initial)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -658,11 +666,11 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
description: firstIssue.path ? `at ${firstIssue.path}` : undefined, description: firstIssue.path ? `at ${firstIssue.path}` : undefined,
}) })
} else { } else {
toast.error(body?.error ?? "Save failed") toast.error(body?.error ?? t("toasts.saveFailed"))
} }
return return
} }
toast.success(isEditing ? "Automation saved" : "Automation created") toast.success(isEditing ? t("toasts.saved") : t("toasts.created"))
if (!isEditing && body?.automation?.id) { if (!isEditing && body?.automation?.id) {
router.replace(`/automations/${body.automation.id}/edit`) router.replace(`/automations/${body.automation.id}/edit`)
} }
@ -681,22 +689,22 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
type="button" type="button"
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back to automations" aria-label={t("backToAutomations")}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </button>
<input <input
value={state.name} value={state.name}
onChange={(e) => patchTop("name", e.target.value)} onChange={(e) => patchTop("name", e.target.value)}
placeholder="Untitled automation" placeholder={t("untitled")}
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base" className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base"
/> />
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">Active</span> <span className="hidden sm:inline">{t("active")}</span>
<Switch <Switch
checked={state.is_active} checked={state.is_active}
onCheckedChange={(v) => patchTop("is_active", !!v)} onCheckedChange={(v) => patchTop("is_active", !!v)}
aria-label="Active" aria-label={t("activeAria")}
/> />
</div> </div>
<Button <Button
@ -705,7 +713,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEditing ? "Save" : "Save Draft"} {isEditing ? t("save") : t("saveDraft")}
</Button> </Button>
</header> </header>
@ -717,8 +725,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
<TriggerCard <TriggerCard
type={state.trigger_type} type={state.trigger_type}
config={state.trigger_config} config={state.trigger_config}
onTypeChange={(t) => patchTop("trigger_type", t)} onTypeChange={(tVal) => patchTop("trigger_type", tVal)}
onConfigChange={(c) => patchTop("trigger_config", c)} onConfigChange={(c) => patchTop("trigger_config", c)}
t={t}
/> />
<StepList <StepList
steps={state.steps} steps={state.steps}
@ -746,11 +755,13 @@ function TriggerCard({
config, config,
onTypeChange, onTypeChange,
onConfigChange, onConfigChange,
t,
}: { }: {
type: AutomationTriggerType type: AutomationTriggerType
config: Record<string, unknown> config: Record<string, unknown>
onTypeChange: (t: AutomationTriggerType) => void onTypeChange: (t: AutomationTriggerType) => void
onConfigChange: (c: Record<string, unknown>) => void onConfigChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@ -767,9 +778,9 @@ function TriggerCard({
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div> <div className="text-[11px] uppercase tracking-wide text-blue-300">{t("trigger")}</div>
<div className="truncate text-sm font-medium text-foreground"> <div className="truncate text-sm font-medium text-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type} {t(`triggers.${type}.label`)}
</div> </div>
</div> </div>
<ChevronDown <ChevronDown
@ -780,7 +791,7 @@ function TriggerCard({
<div className="space-y-3 border-t border-border px-4 py-3"> <div className="space-y-3 border-t border-border px-4 py-3">
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Trigger type {t("triggerType")}
</label> </label>
<select <select
value={type} value={type}
@ -789,18 +800,19 @@ function TriggerCard({
> >
{TRIGGER_OPTIONS.map((o) => ( {TRIGGER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {t(`triggers.${o.value}.label`)}
</option> </option>
))} ))}
</select> </select>
<p className="mt-1 text-[11px] text-muted-foreground"> <p className="mt-1 text-[11px] text-muted-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.hint} {t(`triggers.${type}.hint`)}
</p> </p>
</div> </div>
{type === "keyword_match" && ( {type === "keyword_match" && (
<KeywordMatchConfig <KeywordMatchConfig
config={config as unknown as KeywordMatchTriggerConfig} config={config as unknown as KeywordMatchTriggerConfig}
onChange={onConfigChange} onChange={onConfigChange}
t={t}
/> />
)} )}
{type === "tag_added" && ( {type === "tag_added" && (
@ -811,10 +823,15 @@ function TriggerCard({
<TagSelect <TagSelect
value={(config.tag_id as string) ?? ""} value={(config.tag_id as string) ?? ""}
onChange={(v) => onConfigChange({ ...config, tag_id: v })} onChange={(v) => onConfigChange({ ...config, tag_id: v })}
t={t}
/> />
</div> </div>
)} )}
{type === "time_based" && ( {type === "time_based" && (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{t("schedule")}
</label>
<Input <Input
placeholder="Cron expression or HH:mm" placeholder="Cron expression or HH:mm"
value={(config.schedule as string) ?? ""} value={(config.schedule as string) ?? ""}
@ -823,6 +840,10 @@ function TriggerCard({
} }
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
<p className="mt-1 text-[11px] text-muted-foreground">
{t("scheduleHint")}
</p>
</div>
)} )}
</div> </div>
)} )}
@ -834,9 +855,11 @@ function TriggerCard({
function KeywordMatchConfig({ function KeywordMatchConfig({
config, config,
onChange, onChange,
t,
}: { }: {
config: KeywordMatchTriggerConfig config: KeywordMatchTriggerConfig
onChange: (c: Record<string, unknown>) => void onChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const keywords = config?.keywords ?? [] const keywords = config?.keywords ?? []
// Keep a local draft string so the comma and trailing space aren't // Keep a local draft string so the comma and trailing space aren't
@ -872,7 +895,7 @@ function KeywordMatchConfig({
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Keywords (comma-separated) {t("keywords")}
</label> </label>
<Input <Input
value={draft} value={draft}
@ -884,21 +907,21 @@ function KeywordMatchConfig({
commit() commit()
} }
}} }}
placeholder="e.g. pricing, demo request, talk to sales" placeholder={t("keywordsHint")}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Match type {t("config.matchType")}
</label> </label>
<select <select
value={config?.match_type ?? "contains"} value={config?.match_type ?? "contains"}
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })} onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none"
> >
<option value="contains">Contains</option> <option value="contains">{t("config.matchContains")}</option>
<option value="exact">Exact</option> <option value="exact">{t("config.matchExact")}</option>
</select> </select>
</div> </div>
</div> </div>
@ -972,6 +995,7 @@ function StepRenderer({
parentScope: ParentScope parentScope: ParentScope
parentPath: StepPath parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) { } & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
const path: StepPath = [ const path: StepPath = [
...parentPath, ...parentPath,
parentScope.kind === "root" parentScope.kind === "root"
@ -1011,7 +1035,7 @@ function StepRenderer({
<div className="text-[11px] uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"} {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
</div> </div>
<div className="truncate text-sm font-medium text-foreground">{meta.label}</div> <div className="truncate text-sm font-medium text-foreground">{t(`steps.${meta.label}`)}</div>
<div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div> <div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
</div> </div>
<ChevronDown <ChevronDown
@ -1051,7 +1075,7 @@ function StepRenderer({
onClick={() => props.deleteStepAt(path)} onClick={() => props.deleteStepAt(path)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete {t("delete", { defaultValue: "Delete" })}
</Button> </Button>
</div> </div>
</div> </div>
@ -1083,6 +1107,7 @@ function ConditionBranches({
step: BuilderStep step: BuilderStep
parentPath: StepPath parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) { } & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
const yes = step.branches?.yes ?? [] const yes = step.branches?.yes ?? []
const no = step.branches?.no ?? [] const no = step.branches?.no ?? []
// Build the child scope by appending a branch marker. The scope the // Build the child scope by appending a branch marker. The scope the
@ -1101,10 +1126,10 @@ function ConditionBranches({
// cram each branch to ~170px which is too narrow for the nested // cram each branch to ~170px which is too narrow for the nested
// cards. Two-column grid returns on sm+. // cards. Two-column grid returns on sm+.
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<BranchColumn label="Yes" color="text-primary"> <BranchColumn label={t("branches.yes")} color="text-primary">
<StepList {...props} steps={yes} parentPath={yesPath} /> <StepList {...props} steps={yes} parentPath={yesPath} />
</BranchColumn> </BranchColumn>
<BranchColumn label="No" color="text-rose-400"> <BranchColumn label={t("branches.no")} color="text-rose-400">
<StepList {...props} steps={no} parentPath={noPath} /> <StepList {...props} steps={no} parentPath={noPath} />
</BranchColumn> </BranchColumn>
</div> </div>
@ -1129,13 +1154,14 @@ function BranchColumn({
} }
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
const t = useTranslations("Automations.builder")
return ( return (
<div className="relative flex flex-col items-center"> <div className="relative flex flex-col items-center">
<div className="h-4 w-[2px] bg-border" aria-hidden /> <div className="h-4 w-[2px] bg-border" aria-hidden />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary" className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
aria-label="Add step" aria-label={t("addStep")}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -1143,12 +1169,12 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
align="start" align="start"
className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover" className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover"
> >
{ADDABLE_STEPS.map((t) => { {ADDABLE_STEPS.map((tp) => {
const Icon = STEP_META[t].icon const Icon = STEP_META[tp].icon
return ( return (
<DropdownMenuItem key={t} onClick={() => onPick(t)}> <DropdownMenuItem key={tp} onClick={() => onPick(tp)}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{STEP_META[t].label} {t(`steps.${STEP_META[tp].label}`)}
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
@ -1170,6 +1196,7 @@ function StepEditor({
step: BuilderStep step: BuilderStep
onChange: (s: BuilderStep) => void onChange: (s: BuilderStep) => void
}) { }) {
const t = useTranslations("Automations.builder")
const cfg = step.step_config const cfg = step.step_config
const set = (patch: Record<string, unknown>) => const set = (patch: Record<string, unknown>) =>
onChange({ ...step, step_config: { ...cfg, ...patch } }) onChange({ ...step, step_config: { ...cfg, ...patch } })
@ -1177,11 +1204,11 @@ function StepEditor({
switch (step.step_type) { switch (step.step_type) {
case "send_message": case "send_message":
return ( return (
<FieldBlock label="Message text"> <FieldBlock label={t("config.messageText")}>
<Textarea <Textarea
value={(cfg.text as string) ?? ""} value={(cfg.text as string) ?? ""}
onChange={(e) => set({ text: e.target.value })} onChange={(e) => set({ text: e.target.value })}
placeholder="Hi! Thanks for reaching out…" placeholder={t("config.placeholderMessageText")}
className="min-h-24 bg-muted text-foreground" className="min-h-24 bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
@ -1192,36 +1219,39 @@ function StepEditor({
templateName={(cfg.template_name as string) ?? ""} templateName={(cfg.template_name as string) ?? ""}
language={(cfg.language as string) ?? ""} language={(cfg.language as string) ?? ""}
onChange={(patch) => set(patch)} onChange={(patch) => set(patch)}
t={t}
/> />
) )
case "add_tag": case "add_tag":
case "remove_tag": case "remove_tag":
return ( return (
<FieldBlock label="Tag"> <FieldBlock label={t("config.tagLabel")}>
<TagSelect <TagSelect
value={(cfg.tag_id as string) ?? ""} value={(cfg.tag_id as string) ?? ""}
onChange={(v) => set({ tag_id: v })} onChange={(v) => set({ tag_id: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
) )
case "assign_conversation": case "assign_conversation":
return ( return (
<> <>
<FieldBlock label="Mode"> <FieldBlock label={t("config.modeLabel")}>
<select <select
value={(cfg.mode as string) ?? "round_robin"} value={(cfg.mode as string) ?? "round_robin"}
onChange={(e) => set({ mode: e.target.value })} onChange={(e) => set({ mode: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="round_robin">Round-robin</option> <option value="round_robin">{t("config.modes.round_robin")}</option>
<option value="specific">Specific agent</option> <option value="specific">{t("config.modes.specific")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
{cfg.mode === "specific" && ( {cfg.mode === "specific" && (
<FieldBlock label="Agent"> <FieldBlock label={t("config.agentLabel")}>
<AgentSelect <AgentSelect
value={(cfg.agent_id as string) ?? ""} value={(cfg.agent_id as string) ?? ""}
onChange={(v) => set({ agent_id: v })} onChange={(v) => set({ agent_id: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
)} )}
@ -1230,17 +1260,18 @@ function StepEditor({
case "update_contact_field": case "update_contact_field":
return ( return (
<> <>
<FieldBlock label="Field"> <FieldBlock label={t("config.fieldLabel")}>
<ContactFieldSelect <ContactFieldSelect
value={(cfg.field as string) ?? "name"} value={(cfg.field as string) ?? "name"}
onChange={(v) => set({ field: v })} onChange={(v) => set({ field: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label={t("config.valueLabel")}>
<Input <Input
value={(cfg.value as string) ?? ""} value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })} onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}" placeholder={t("config.placeholderValue")}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
@ -1253,15 +1284,16 @@ function StepEditor({
pipelineId={(cfg.pipeline_id as string) ?? ""} pipelineId={(cfg.pipeline_id as string) ?? ""}
stageId={(cfg.stage_id as string) ?? ""} stageId={(cfg.stage_id as string) ?? ""}
onChange={(patch) => set(patch)} onChange={(patch) => set(patch)}
t={t}
/> />
<FieldBlock label="Title"> <FieldBlock label={t("config.titleLabel")}>
<Input <Input
value={(cfg.title as string) ?? ""} value={(cfg.title as string) ?? ""}
onChange={(e) => set({ title: e.target.value })} onChange={(e) => set({ title: e.target.value })}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label={t("config.valueLabel")}>
<Input <Input
type="number" type="number"
value={(cfg.value as number) ?? 0} value={(cfg.value as number) ?? 0}
@ -1274,7 +1306,7 @@ function StepEditor({
case "wait": case "wait":
return ( return (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FieldBlock label="Amount"> <FieldBlock label={t("config.amountLabel")}>
<Input <Input
type="number" type="number"
min={1} min={1}
@ -1283,15 +1315,15 @@ function StepEditor({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Unit"> <FieldBlock label={t("config.unitLabel")}>
<select <select
value={(cfg.unit as string) ?? "hours"} value={(cfg.unit as string) ?? "hours"}
onChange={(e) => set({ unit: e.target.value })} onChange={(e) => set({ unit: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="minutes">Minutes</option> <option value="minutes">{t("config.units.minutes")}</option>
<option value="hours">Hours</option> <option value="hours">{t("config.units.hours")}</option>
<option value="days">Days</option> <option value="days">{t("config.units.days")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
</div> </div>
@ -1299,27 +1331,27 @@ function StepEditor({
case "condition": case "condition":
return ( return (
<> <>
<FieldBlock label="Subject"> <FieldBlock label={t("config.subjectLabel")}>
<select <select
value={(cfg.subject as string) ?? "tag_presence"} value={(cfg.subject as string) ?? "tag_presence"}
onChange={(e) => set({ subject: e.target.value })} onChange={(e) => set({ subject: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="tag_presence">Tag presence</option> <option value="tag_presence">{t("config.subjects.tag_presence")}</option>
<option value="contact_field">Contact field</option> <option value="contact_field">{t("config.subjects.contact_field")}</option>
<option value="message_content">Message content</option> <option value="message_content">{t("config.subjects.message_content")}</option>
<option value="time_of_day">Time of day</option> <option value="time_of_day">{t("config.subjects.time_of_day")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
<FieldBlock label="Operand"> <FieldBlock label={t("config.operandLabel")}>
<Input <Input
placeholder={ placeholder={
cfg.subject === "time_of_day" cfg.subject === "time_of_day"
? "HH:mm-HH:mm" ? t("config.placeholderTime")
: cfg.subject === "contact_field" : cfg.subject === "contact_field"
? "name / email / company" ? t("config.placeholderContact")
: cfg.subject === "tag_presence" : cfg.subject === "tag_presence"
? "tag id" ? t("config.placeholderTag")
: "" : ""
} }
value={(cfg.operand as string) ?? ""} value={(cfg.operand as string) ?? ""}
@ -1341,14 +1373,14 @@ function StepEditor({
case "send_webhook": case "send_webhook":
return ( return (
<> <>
<FieldBlock label="URL"> <FieldBlock label={t("config.urlLabel")}>
<Input <Input
value={(cfg.url as string) ?? ""} value={(cfg.url as string) ?? ""}
onChange={(e) => set({ url: e.target.value })} onChange={(e) => set({ url: e.target.value })}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Body template (JSON)"> <FieldBlock label={t("config.bodyTemplateLabel")}>
<Textarea <Textarea
value={(cfg.body_template as string) ?? ""} value={(cfg.body_template as string) ?? ""}
onChange={(e) => set({ body_template: e.target.value })} onChange={(e) => set({ body_template: e.target.value })}
@ -1360,7 +1392,7 @@ function StepEditor({
case "close_conversation": case "close_conversation":
return ( return (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sets the conversation status to &quot;closed&quot;. No configuration needed. {t("config.closeConversationHint", { defaultValue: "Sets the conversation status to \"closed\". No configuration needed." })}
</p> </p>
) )
default: default: