mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
i18n: Localize AutomationBuilder component and fix typing
This commit is contained in:
parent
92e5789837
commit
d518d54520
177
messages/en.json
177
messages/en.json
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,18 +823,27 @@ 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" && (
|
||||||
<Input
|
<div>
|
||||||
placeholder="Cron expression or HH:mm"
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||||
value={(config.schedule as string) ?? ""}
|
{t("schedule")}
|
||||||
onChange={(e) =>
|
</label>
|
||||||
onConfigChange({ ...config, schedule: e.target.value })
|
<Input
|
||||||
}
|
placeholder="Cron expression or HH:mm"
|
||||||
className="bg-muted text-foreground"
|
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>
|
</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 "closed". No configuration needed.
|
{t("config.closeConversationHint", { defaultValue: "Sets the conversation status to \"closed\". No configuration needed." })}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user