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

View File

@ -10,6 +10,7 @@ import {
ChevronDown,
ChevronRight,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { createClient } from "@/lib/supabase/client"
import type {
@ -28,6 +29,7 @@ export default function AutomationLogsPage({
}) {
const { id } = use(params)
const router = useRouter()
const t = useTranslations("Automations.logs")
const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null)
@ -56,7 +58,7 @@ export default function AutomationLogsPage({
setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs")
setError(err instanceof Error ? err.message : t("loadError"))
}
}
load()
@ -67,7 +69,7 @@ export default function AutomationLogsPage({
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}>
Back
{t("back")}
</Button>
</div>
)
@ -88,21 +90,21 @@ export default function AutomationLogsPage({
type="button"
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"
aria-label="Back"
aria-label={t("backAria")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<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>
{logs.length === 0 ? (
<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">
Trigger this automation to see runs here.
{t("emptyDesc")}
</p>
</div>
) : (
@ -124,14 +126,14 @@ export default function AutomationLogsPage({
) : (
<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="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 className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
{log.trigger_event} · {log.steps_executed?.length ?? 0}{" "}
{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 className="text-xs text-muted-foreground">
@ -150,7 +152,7 @@ export default function AutomationLogsPage({
<StepRow key={i} result={r} />
))}
{(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>
</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 =
status === "success"
? "border-primary/30 bg-primary/10 text-primary"
@ -178,7 +180,7 @@ function StatusBadge({ status }: { status: AutomationLog["status"] }) {
classes,
)}
>
{status}
{t(`status.${status}`)}
</span>
)
}

View File

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

View File

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