feat(i18n): complete internationalization of flow builder components

This commit is contained in:
Ivan Mercedes 2026-07-07 19:43:26 -04:00
parent d518d54520
commit caf0f3660e
9 changed files with 412 additions and 190 deletions

View File

@ -913,7 +913,168 @@
"placeholderBody": "{\"id\": \"{{ contact.id }}\"}",
"matchType": "Match type",
"matchContains": "Contains",
"matchExact": "Exact"
"matchExact": "Exact",
"closeConversationHint": "Sets the conversation status to \"closed\". No configuration needed."
}
}
},
"Flows": {
"list": {
"title": "Flows",
"beta": "Beta",
"description": "Build branching, button-driven WhatsApp conversations. Useful for menus, FAQs, and triage before a human steps in.",
"newFlow": "New flow",
"statusDraft": "Draft",
"statusActive": "Active",
"statusArchived": "Archived",
"loadError": "Couldn't load flows.",
"createError": "Couldn't create flow.",
"cloneError": "Clone failed",
"deleteConfirm": "Delete \"{name}\"? Any active runs will end immediately.",
"deleteSuccess": "Flow deleted.",
"deleteError": "Couldn't delete flow.",
"emptyTitle": "No flows yet",
"emptyDesc": "Build your first conversation — a welcome menu, an order lookup, an FAQ bot. Customers tap buttons; the bot routes them to the right answer (or the right agent).",
"createFirst": "Create your first flow",
"createTitle": "Create a new flow",
"createDesc": "Start from a template or build from scratch.",
"startTemplate": "Start from a template",
"nodeCount": "{count} {count, plural, =1 {node} other {nodes}}",
"startBlank": "Or start blank",
"placeholderName": "e.g. Welcome menu",
"cancel": "Cancel",
"createBlank": "Create blank flow",
"runCount": "{count} {count, plural, =1 {run} other {runs}}",
"edit": "Edit",
"delete": "Delete",
"triggerKeywordNone": "Triggers on keyword (none set)",
"triggerKeyword": "Triggers on: {keywords}",
"triggerFirstInbound": "Triggers on a contact's first-ever inbound message",
"triggerManual": "Manual trigger"
},
"edit": {
"notFound": "Flow not found.",
"backToFlows": "← Back to flows",
"loadError": "Couldn't load flow."
},
"logs": {
"title": "Runs",
"description": "The 50 most recent times this flow ran. Expand a row to see the engine's per-step log.",
"emptyState": "No runs yet. Trigger the flow from a personal WhatsApp number to see it appear here.",
"loadError": "Couldn't load runs.",
"unknownContact": "Unknown contact",
"atNode": "at {node}",
"started": "Started {time}",
"reprompts": "{count} re-prompts",
"ranFor": "ran for {duration}",
"capturedVars": "Captured vars ({count})",
"noEvents": "No events recorded for this run.",
"statusActive": "Active",
"statusCompleted": "Completed",
"statusHandedOff": "Handed off",
"statusTimedOut": "Timed out",
"statusPaused": "Paused by agent",
"statusFailed": "Failed"
},
"builder": {
"triggerTitle": "Trigger",
"whenLabel": "When…",
"triggerKeywordTitle": "A message contains a keyword",
"triggerFirstInboundTitle": "Customer's first ever inbound message",
"triggerManualTitle": "Manual only (no auto-trigger)",
"keywordsLabel": "Keywords (comma-separated)",
"keywordsPlaceholder": "support, help, hi",
"entryNodeTitle": "Entry node:",
"entryNodePlaceholder": "Pick the first node…",
"nodesTitle": "Nodes ({count})",
"nodesEmpty": "Add a <strong>Start</strong> node, then a <strong>Send buttons</strong> node, then a <strong>Handoff</strong> — that's the welcome-menu shape from the brief.",
"addNode": "Add node",
"setAsEntry": "Set as entry",
"removeNode": "Remove node",
"showAdvanced": "Show advanced",
"hideAdvanced": "Hide advanced",
"nodeKeyLabel": "Node key (internal identifier — keep stable for analytics)",
"replyIdsHint": "Reply IDs for each option are shown inline above. They're returned by WhatsApp when a customer taps; you usually don't need to touch them.",
"badgeEntry": "Entry",
"noNodesYet": "No nodes yet.",
"deleteNode": "Delete node",
"canvasView": "Canvas",
"listView": "List",
"form": {
"advancesTo": "Advances to",
"textToCustomer": "Text sent to the customer",
"promptToCustomer": "Prompt sent to the customer",
"varKeyLabel": "Variable key (stored in flow_runs.vars; alphanumeric + underscore)",
"varKeyPlaceholder": "e.g. name, email, company",
"varKeyHelp": "Interpolate in downstream prompts and handoff notes with",
"advanceAfterCapture": "After capturing, advance to",
"internalNote": "Internal note (for the agent picking up)",
"endNodeHelp": "Terminal node. When the runner reaches this node the run is marked complete. No config needed.",
"bodyText": "Body text",
"footerText": "Footer (optional, 60 chars)",
"buttonsHelp": "Buttons (1–3) — each one routes to a different next node",
"optionTitlePlaceholder": "Visible title (≤20 chars)",
"nextNodePlaceholder": "Next node…",
"addButton": "Add button",
"buttonLabel": "Tap-to-expand button label (≤20 chars)",
"rowsHelp": "Rows (1–10 total across all sections)",
"sectionTitlePlaceholder": "Section {count} title (optional)",
"rowTitlePlaceholder": "Row title (≤24)",
"addRow": "Add row",
"addSection": "Add section",
"ifLabel": "If",
"capturedVariable": "Captured variable",
"contactHasTag": "Contact has tag",
"contactField": "Contact field",
"varName": "var name",
"tagLabel": "Tag",
"fieldLabel": "Field",
"pickTag": "Pick a tag…",
"pickField": "Pick a field…",
"operatorLabel": "Operator",
"isPresent": "is present",
"isAbsent": "is absent",
"equals": "equals",
"contains": "contains",
"valueLabel": "Value",
"ifTrueAdvance": "If true → advance to",
"ifFalseAdvance": "If false → advance to",
"actionLabel": "Action",
"addTag": "Add tag",
"removeTag": "Remove tag",
"tagUuidPlaceholder": "Tag UUID",
"thenAdvanceTo": "Then advance to",
"mediaTypeLabel": "Media type",
"imageLabel": "Image (PNG, JPEG, WebP)",
"videoLabel": "Video (MP4, 3GP)",
"documentLabel": "Document (PDF, Word, Excel, PowerPoint, TXT)",
"fileLabel": "File",
"removeFile": "Remove file",
"uploading": "Uploading…",
"clickToUpload": "Click to upload (max 16 MB)",
"captionLabel": "Caption (optional, shown under the media)",
"filenameLabel": "Filename shown to the customer (documents only)",
"filenamePlaceholder": "invoice.pdf",
"advanceAfterSending": "After sending, advance to",
"pickNextNode": "Pick a next node…",
"none": "— None —"
},
"categories": {
"messaging": "Messaging",
"logic": "Logic & data",
"flow": "Flow control"
},
"nodes": {
"start": { "label": "Start", "blurb": "Entry point of the flow" },
"send_message": { "label": "Send message", "blurb": "Sends a WhatsApp text message" },
"send_buttons": { "label": "Send buttons", "blurb": "Sends quick-reply buttons" },
"send_list": { "label": "Send list", "blurb": "Sends a tappable list of options" },
"send_media": { "label": "Send media", "blurb": "Sends an image, video, or document" },
"collect_input": { "label": "Collect input", "blurb": "Asks a question, saves the reply" },
"condition": { "label": "If / else", "blurb": "Branches on a rule" },
"set_tag": { "label": "Tag contact", "blurb": "Adds or removes a contact tag" },
"handoff": { "label": "Handoff to agent", "blurb": "Hands the conversation to a human" },
"end": { "label": "End", "blurb": "Ends the flow" }
}
}
}

View File

@ -5,6 +5,8 @@ import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -23,6 +25,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
export default function FlowEditorPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const t = useTranslations("Flows.edit");
const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
@ -51,7 +54,7 @@ export default function FlowEditorPage() {
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flow.");
toast.error(t("loadError"));
}
} finally {
if (!cancelled) setLoading(false);
@ -72,13 +75,13 @@ export default function FlowEditorPage() {
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
← Back to flows
{t("backToFlows")}
</button>
</div>
);

View File

@ -17,6 +17,8 @@ import {
import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@ -96,6 +98,8 @@ const STATUS_META: Record<
export default function FlowRunsPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const t = useTranslations("Flows.logs");
const tEdit = useTranslations("Flows.edit");
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]);
@ -128,7 +132,7 @@ export default function FlowRunsPage() {
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load runs.");
toast.error(t("loadError"));
}
} finally {
if (!cancelled) setLoading(false);
@ -158,13 +162,13 @@ export default function FlowRunsPage() {
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p>
<p className="text-sm text-muted-foreground">{tEdit("notFound")}</p>
<button
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
← Back to flows
{tEdit("backToFlows")}
</button>
</div>
);
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
<ArrowLeft className="h-3 w-3" />
{flow.name}
</button>
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
<h1 className="text-xl font-semibold text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
{t("description")}
</p>
{runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
{t("emptyState")}
</div>
) : (
<div className="mt-6 flex flex-col gap-2">
@ -200,6 +202,7 @@ export default function FlowRunsPage() {
events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
t={t}
/>
))}
</div>
@ -213,16 +216,18 @@ function RunCard({
events,
expanded,
onToggle,
t,
}: {
run: RunRow;
events: EventRow[];
expanded: boolean;
onToggle: () => void;
t: ReturnType<typeof useTranslations>;
}) {
const meta = STATUS_META[run.status];
const StatusIcon = meta.icon;
const contactLabel =
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact";
run.contact?.name?.trim() || run.contact?.phone || t("unknownContact");
const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false,
@ -247,20 +252,32 @@ function RunCard({
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" />
{meta.label}
{t(
run.status === "active"
? "statusActive"
: run.status === "completed"
? "statusCompleted"
: run.status === "handed_off"
? "statusHandedOff"
: run.status === "timed_out"
? "statusTimedOut"
: run.status === "paused_by_agent"
? "statusPaused"
: "statusFailed"
)}
</Badge>
{run.status === "active" && run.current_node_key && (
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
at {run.current_node_key}
{t("atNode", { node: run.current_node_key })}
</code>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Started {format(new Date(run.started_at), "PP p")}</span>
<span>{t("started", { time: format(new Date(run.started_at), "PP p") })}</span>
{run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span>
<span>· {t("reprompts", { count: run.reprompt_count })}</span>
)}
{duration && <span>· ran for {duration}</span>}
{duration && <span>· {t("ranFor", { duration })}</span>}
</div>
</div>
</button>
@ -269,7 +286,7 @@ function RunCard({
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-xs text-muted-foreground">
Captured vars ({Object.keys(run.vars).length})
{t("capturedVars", { count: Object.keys(run.vars).length })}
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)}
@ -279,7 +296,7 @@ function RunCard({
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-muted-foreground">
No events recorded for this run.
{t("noEvents")}
</p>
) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)

View File

@ -18,6 +18,7 @@ import {
FileText,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCan } from "@/hooks/use-can";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
@ -54,11 +55,11 @@ interface FlowRow {
updated_at: string;
}
const STATUS_LABELS: Record<FlowRow["status"], string> = {
draft: "Draft",
active: "Active",
archived: "Archived",
};
const STATUS_LABELS = (t: ReturnType<typeof useTranslations>): Record<FlowRow["status"], string> => ({
draft: t("statusDraft"),
active: t("statusActive"),
archived: t("statusArchived"),
});
const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-border bg-muted text-muted-foreground",
@ -84,6 +85,7 @@ const TEMPLATE_ICONS = {
export default function FlowsPage() {
const router = useRouter();
const canCreate = useCan("send-messages");
const t = useTranslations("Flows.list");
const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
@ -115,7 +117,7 @@ export default function FlowsPage() {
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load flows.");
toast.error(t("loadError"));
}
} finally {
if (!cancelled) setLoading(false);
@ -146,7 +148,7 @@ export default function FlowsPage() {
router.push(`/flows/${json.flow.id}`);
} catch (err) {
console.error(err);
toast.error("Couldn't create flow.");
toast.error(t("createError"));
} finally {
setCreating(false);
}
@ -168,7 +170,7 @@ export default function FlowsPage() {
setCreateOpen(false);
router.push(`/flows/${json.flow.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed";
const msg = err instanceof Error ? err.message : t("cloneError");
toast.error(msg);
} finally {
setCreating(false);
@ -176,18 +178,16 @@ export default function FlowsPage() {
}
async function handleDelete(flow: FlowRow) {
const yes = window.confirm(
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
const yes = window.confirm(t("deleteConfirm", { name: flow.name }));
if (!yes) return;
try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted.");
toast.success(t("deleteSuccess"));
} catch (err) {
console.error(err);
toast.error("Couldn't delete flow.");
toast.error(t("deleteError"));
}
}
@ -204,14 +204,13 @@ export default function FlowsPage() {
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
<h1 className="text-2xl font-semibold text-foreground">{t("title")}</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta
{t("beta")}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in.
{t("description")}
</p>
</div>
<GatedButton
@ -220,7 +219,7 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New flow
{t("newFlow")}
</GatedButton>
</header>
@ -228,6 +227,7 @@ export default function FlowsPage() {
<EmptyState
onCreate={() => setCreateOpen(true)}
canCreate={canCreate}
t={t}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@ -237,6 +237,7 @@ export default function FlowsPage() {
flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)}
t={t}
/>
))}
</div>
@ -249,37 +250,37 @@ export default function FlowsPage() {
sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>Create a new flow</DialogTitle>
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start from a template or build from scratch.
{t("createDesc")}
</DialogDescription>
</DialogHeader>
{templates.length > 0 && (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template
{t("startTemplate")}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
{templates.map((template) => {
const Icon = TEMPLATE_ICONS[template.icon] ?? FileText;
return (
<button
key={t.slug}
key={template.slug}
type="button"
onClick={() => handleUseTemplate(t.slug)}
onClick={() => handleUseTemplate(template.slug)}
disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
>
<Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-popover-foreground">
{t.name}
{template.name}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{t.description}
{template.description}
</span>
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
{t("nodeCount", { count: template.node_count })}
</span>
</button>
);
@ -290,12 +291,12 @@ export default function FlowsPage() {
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank
{t("startBlank")}
</p>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu"
placeholder={t("placeholderName")}
className="bg-muted"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
@ -309,11 +310,11 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(false)}
disabled={creating}
>
Cancel
{t("cancel")}
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create blank flow
{t("createBlank")}
</Button>
</DialogFooter>
</DialogContent>
@ -325,9 +326,11 @@ export default function FlowsPage() {
function EmptyState({
onCreate,
canCreate,
t,
}: {
onCreate: () => void;
canCreate: boolean;
t: ReturnType<typeof useTranslations>;
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
@ -335,12 +338,10 @@ function EmptyState({
<Workflow className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="mt-4 text-base font-medium text-foreground">
No flows yet
{t("emptyTitle")}
</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation — a welcome menu, an order lookup, an FAQ
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
{t("emptyDesc")}
</p>
<GatedButton
canAct={canCreate}
@ -349,7 +350,7 @@ function EmptyState({
className="mt-5"
>
<Plus className="h-4 w-4" />
Create your first flow
{t("createFirst")}
</GatedButton>
</div>
);
@ -359,12 +360,14 @@ function FlowCard({
flow,
onEdit,
onDelete,
t,
}: {
flow: FlowRow;
onEdit: () => void;
onDelete: () => void;
t: ReturnType<typeof useTranslations>;
}) {
const triggerSummary = describeTrigger(flow);
const triggerSummary = describeTrigger(flow, t);
const StatusIcon =
flow.status === "active"
? PlayCircle
@ -388,7 +391,7 @@ function FlowCard({
)}
>
<StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]}
{STATUS_LABELS(t)[flow.status]}
</Badge>
</div>
@ -399,14 +402,14 @@ function FlowCard({
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
{t("runCount", { count: flow.execution_count })}
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
{t("edit")}
</Button>
<Button
variant="ghost"
@ -415,23 +418,23 @@ function FlowCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
{t("delete")}
</Button>
</div>
</div>
);
}
function describeTrigger(flow: FlowRow): string {
function describeTrigger(flow: FlowRow, t: ReturnType<typeof useTranslations>): string {
if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[])
: [];
if (keywords.length === 0) return "Triggers on keyword (none set)";
return `Triggers on: ${keywords.join(", ")}`;
if (keywords.length === 0) return t("triggerKeywordNone");
return t("triggerKeyword", { keywords: keywords.join(", ") });
}
if (flow.trigger_type === "first_inbound_message") {
return "Triggers on a contact's first-ever inbound message";
return t("triggerFirstInbound");
}
return "Manual trigger";
return t("triggerManual");
}

View File

@ -18,6 +18,7 @@
*/
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
CircleAlert,
Plus,
@ -73,6 +74,7 @@ import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================
export function FlowBuilder() {
const t = useTranslations('Flows.builder');
const {
state,
setState,
@ -158,24 +160,21 @@ export function FlowBuilder() {
state={state}
setState={setState}
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
t={t}
/>
<EntryPicker state={state} setState={setState} />
<EntryPicker state={state} setState={setState} t={t} />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length})
{t('nodesTitle', { count: state.nodes.length })}
</h2>
<AddNodeButton onAdd={addNode} />
<AddNodeButton onAdd={addNode} t={t} />
</div>
{state.nodes.length === 0 ? (
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a{' '}
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
— that&apos;s the welcome-menu shape from the brief.
</div>
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm" dangerouslySetInnerHTML={{ __html: t.raw('nodesEmpty') }} />
) : (
state.nodes.map((node) => (
<NodeCard
@ -196,6 +195,7 @@ export function FlowBuilder() {
onSetEntry={() =>
setState((s) => ({ ...s, entry_node_id: node.node_key }))
}
t={t}
/>
))
)}
@ -221,9 +221,11 @@ export function FlowBuilder() {
function KeywordsInput({
keywords,
onChange,
t,
}: {
keywords: string[];
onChange: (keywords: string[]) => void;
t: ReturnType<typeof useTranslations>;
}) {
const [draft, setDraft] = useState(keywords.join(', '));
@ -247,7 +249,7 @@ function KeywordsInput({
commit();
}
}}
placeholder="support, help, hi"
placeholder={t('keywordsPlaceholder')}
className="bg-muted"
/>
);
@ -261,18 +263,20 @@ function TriggerPanel({
state,
setState,
triggerIssues,
t,
}: {
state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
triggerIssues: ValidationIssue[];
t: ReturnType<typeof useTranslations>;
}) {
return (
<section className="border-border bg-card rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
<h2 className="text-foreground mb-3 text-sm font-semibold">{t('triggerTitle')}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="text-muted-foreground mb-1 block text-xs">
When…
{t('whenLabel')}
</label>
<Select
value={state.trigger_type}
@ -290,13 +294,13 @@ function TriggerPanel({
</SelectTrigger>
<SelectContent>
<SelectItem value="keyword">
A message contains a keyword
{t('triggerKeywordTitle')}
</SelectItem>
<SelectItem value="first_inbound_message">
Customer&apos;s first ever inbound message
{t('triggerFirstInboundTitle')}
</SelectItem>
<SelectItem value="manual">
Manual only (no auto-trigger)
{t('triggerManualTitle')}
</SelectItem>
</SelectContent>
</Select>
@ -304,7 +308,7 @@ function TriggerPanel({
{state.trigger_type === 'keyword' && (
<div>
<label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated)
{t('keywordsLabel')}
</label>
<KeywordsInput
keywords={
@ -318,6 +322,7 @@ function TriggerPanel({
trigger_config: { ...s.trigger_config, keywords },
}))
}
t={t}
/>
</div>
)}
@ -340,20 +345,22 @@ function TriggerPanel({
function EntryPicker({
state,
setState,
t,
}: {
state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
t: ReturnType<typeof useTranslations>;
}) {
if (state.nodes.length === 0) return null;
return (
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Entry node:</span>
<span className="text-muted-foreground text-xs">{t('entryNodeTitle')}</span>
<NodeKeySelect
value={state.entry_node_id}
nodes={state.nodes}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…"
placeholder={t('entryNodePlaceholder')}
className="max-w-xs flex-1"
/>
</section>
@ -377,6 +384,7 @@ function NodeCard({
onUpdateConfig,
onRemove,
onSetEntry,
t,
}: {
node: BuilderNode;
allNodes: BuilderNode[];
@ -390,6 +398,7 @@ function NodeCard({
onUpdateConfig: (patch: Record<string, unknown>) => void;
onRemove: () => void;
onSetEntry: () => void;
t: ReturnType<typeof useTranslations>;
}) {
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
@ -425,7 +434,7 @@ function NodeCard({
className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
{t(`nodes.${node.node_type}.label`)}
</span>
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key}
@ -435,7 +444,7 @@ function NodeCard({
variant="outline"
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
>
Entry
{t('badgeEntry')}
</Badge>
)}
</div>
@ -461,12 +470,13 @@ function NodeCard({
allNodes={allNodes}
onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig}
t={t}
/>
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
{!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
{t('setAsEntry')}
</Button>
)}
</div>
@ -477,7 +487,7 @@ function NodeCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Remove node
{t('removeNode')}
</Button>
</div>
{issues.length > 0 && (
@ -504,11 +514,13 @@ function NodeConfigWithAdvanced({
allNodes,
onUpdate,
onUpdateConfig,
t,
}: {
node: BuilderNode;
allNodes: BuilderNode[];
onUpdate: (patch: Partial<BuilderNode>) => void;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds =
@ -532,13 +544,13 @@ function NodeConfigWithAdvanced({
) : (
<ChevronDown className="h-3 w-3" />
)}
{showAdvanced ? 'Hide' : 'Show'} advanced
{showAdvanced ? t('hideAdvanced') : t('showAdvanced')}
</button>
{showAdvanced && (
<div className="mt-3 flex flex-col gap-3">
<div>
<label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics)
{t('nodeKeyLabel')}
</label>
<Input
value={node.node_key}
@ -550,9 +562,7 @@ function NodeConfigWithAdvanced({
</div>
{hasReplyIds && (
<p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
{t('replyIdsHint')}
</p>
)}
</div>
@ -566,7 +576,7 @@ function NodeConfigWithAdvanced({
// Add-node menu
// ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
function AddNodeButton({ onAdd, t }: { onAdd: (type: NodeType) => void; t: ReturnType<typeof useTranslations> }) {
const types: NodeType[] = [
'start',
'send_buttons',
@ -583,10 +593,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
<DropdownMenu>
<DropdownMenuTrigger
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node"
aria-label={t('addNode')}
>
<Plus className="h-3.5 w-3.5" />
Add node
{t('addNode')}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
{groupNodeTypesByCategory(types).map((group, i) => (
@ -598,14 +608,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label}
{t(`categories.${group.id}`)}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
{group.types.map((t_type) => {
const meta = NODE_META[t_type];
return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<DropdownMenuItem key={t_type} onClick={() => onAdd(t_type)}>
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label}
{t(`nodes.${t_type}.label`)}
</DropdownMenuItem>
);
})}

View File

@ -59,6 +59,8 @@ import {
import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
@ -132,6 +134,7 @@ function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
}
function FlowNodeCard({ data, selected }: NodeProps) {
const t = useTranslations('Flows.builder');
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
@ -191,11 +194,11 @@ function FlowNodeCard({ data, selected }: NodeProps) {
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
{t(`nodes.${node.node_type}.label`)}
</span>
{isEntry && (
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry
{t('badgeEntry')}
</span>
)}
</div>
@ -270,6 +273,7 @@ export function FlowCanvas() {
}
function FlowCanvasInner() {
const t = useTranslations('Flows.builder');
const {
state,
setState,
@ -508,8 +512,8 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
<p>{t('noNodesYet')}</p>
<CanvasAddNodeButton t={t} />
</div>
);
}
@ -565,7 +569,7 @@ function FlowCanvasInner() {
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/>
<Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton />
<CanvasAddNodeButton t={t} />
</Panel>
</ReactFlow>
</div>
@ -578,6 +582,7 @@ function FlowCanvasInner() {
onUpdateConfig={onSelectedUpdateConfig}
onDelete={handleDeleteSelected}
onSetEntry={handleSetEntry}
t={t}
/>
</>
);
@ -597,6 +602,7 @@ function NodeEditSheet({
onUpdateConfig,
onDelete,
onSetEntry,
t,
}: {
node: BuilderNode | null;
isEntry: boolean;
@ -605,6 +611,7 @@ function NodeEditSheet({
onUpdateConfig: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onSetEntry: () => void;
t: ReturnType<typeof useTranslations>;
}) {
// Sheet is controlled — opens when a node is selected, closes via
// Esc / overlay / close button (all delegated to onClose).
@ -628,15 +635,15 @@ function NodeEditSheet({
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span>
<span style={{ color: c.text }}>{t(`nodes.${node.node_type}.label`)}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry
{t('badgeEntry')}
</span>
)}
</SheetTitle>
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb}
{t(`nodes.${node.node_type}.blurb`)}
</SheetDescription>
</div>
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
@ -656,7 +663,7 @@ function NodeEditSheet({
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
{t('setAsEntry')}
</Button>
) : (
<span />
@ -668,7 +675,7 @@ function NodeEditSheet({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
Delete node
{t('deleteNode')}
</Button>
</SheetFooter>
</SheetContent>
@ -696,7 +703,7 @@ const ADD_NODE_TYPES: NodeType[] = [
'end',
];
function CanvasAddNodeButton() {
function CanvasAddNodeButton({ t }: { t: ReturnType<typeof useTranslations> }) {
const reactFlow = useReactFlow();
const { addNode, updateNodePosition } = useFlowEditor();
@ -728,10 +735,10 @@ function CanvasAddNodeButton() {
<DropdownMenu>
<DropdownMenuTrigger
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node"
aria-label={t('addNode')}
>
<Plus className="h-4 w-4" />
Add node
{t('addNode')}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
@ -746,28 +753,28 @@ function CanvasAddNodeButton() {
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label}
{t(`categories.${group.id}`)}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
{group.types.map((t_type) => {
const meta = NODE_META[t_type];
return (
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
key={t_type}
onClick={() => handleAdd(t_type)}
className="gap-3 py-2"
>
<NodeIconChip
type={t}
type={t_type}
size={28}
iconSize={16}
className="rounded-md"
/>
<span className="flex flex-col">
<span className="text-popover-foreground text-[13px] font-semibold">
{meta.label}
{t(`nodes.${t_type}.label`)}
</span>
<span className="text-muted-foreground text-[11.5px]">
{meta.blurb}
{t(`nodes.${t_type}.blurb`)}
</span>
</span>
</DropdownMenuItem>

View File

@ -35,6 +35,7 @@ import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
import { useTranslations } from "next-intl";
/**
* Below this viewport width we force list view and hide the toggle.
@ -59,6 +60,8 @@ interface Props {
}
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
const t = useTranslations("Flows.builder");
// Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only
// mounts us AFTER a client-side fetch resolves — there's no SSR
@ -110,26 +113,26 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
active={effectiveView === "canvas"}
onClick={() => choose("canvas")}
icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas"
label={t("canvasView")}
/>
<SegButton
active={effectiveView === "list"}
onClick={() => choose("list")}
icon={<List className="h-3.5 w-3.5" />}
label="List"
label={t("listView")}
/>
</div>
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
{LEGEND_TYPES.map((t) => (
{LEGEND_TYPES.map((t_type) => (
<span
key={t}
key={t_type}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: nodeColors(t).solid }}
style={{ background: nodeColors(t_type).solid }}
/>
{NODE_META[t].label}
{t(`nodes.${t_type}.label`)}
</span>
))}
</div>

View File

@ -28,6 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { NODE_META, type BuilderNode } from "../shared";
export function TextRow({
@ -83,7 +84,7 @@ export function NextNodeRow({
nodes={allNodes}
excludeKey={currentKey}
onChange={(v) => onChange(v ?? "")}
placeholder="Pick a next node…"
placeholder={useTranslations("Flows.builder.form")("pickNextNode")}
/>
</div>
);
@ -104,6 +105,7 @@ export function NodeKeySelect({
placeholder?: string;
className?: string;
}) {
const t = useTranslations("Flows.builder.form");
const options = nodes.filter((n) => n.node_key !== excludeKey);
return (
<Select
@ -114,7 +116,7 @@ export function NodeKeySelect({
<SelectValue placeholder={placeholder ?? "—"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">— None —</SelectItem>
<SelectItem value="__none__">{t("none")}</SelectItem>
{options.map((n) => {
const Icon = NODE_META[n.node_type].icon;
return (

View File

@ -34,6 +34,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -62,6 +63,7 @@ export function NodeConfigForm({
showAdvanced,
onUpdateConfig,
}: NodeConfigFormProps) {
const t = useTranslations("Flows.builder.form");
const cfg = node.config;
switch (node.node_type) {
case "start":
@ -71,7 +73,7 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Advances to"
label={t("advancesTo")}
/>
);
@ -79,7 +81,7 @@ export function NodeConfigForm({
return (
<>
<TextRow
label="Text sent to the customer"
label={t("textToCustomer")}
value={(cfg as { text?: string }).text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })}
/>
@ -88,7 +90,7 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Advances to"
label={t("advancesTo")}
/>
</>
);
@ -101,6 +103,7 @@ export function NodeConfigForm({
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced}
t={t}
/>
);
@ -112,6 +115,7 @@ export function NodeConfigForm({
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced}
t={t}
/>
);
@ -122,6 +126,7 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
@ -129,14 +134,14 @@ export function NodeConfigForm({
return (
<>
<TextRow
label="Prompt sent to the customer"
label={t("promptToCustomer")}
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
onChange={(v) => onUpdateConfig({ prompt_text: v })}
rows={2}
/>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
{t("varKeyLabel")}
</label>
<Input
value={(cfg as { var_key?: string }).var_key ?? ""}
@ -145,11 +150,11 @@ export function NodeConfigForm({
var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""),
})
}
placeholder="e.g. name, email, company"
placeholder={t("varKeyPlaceholder")}
className="bg-muted font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
Interpolate in downstream prompts and handoff notes with{" "}
{t("varKeyHelp")}{" "}
<code className="rounded bg-muted px-1">
{"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"}
@ -163,7 +168,7 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After capturing, advance to"
label={t("advanceAfterCapture")}
/>
</>
);
@ -175,6 +180,7 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
@ -185,13 +191,14 @@ export function NodeConfigForm({
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
case "handoff":
return (
<TextRow
label="Internal note (for the agent picking up)"
label={t("internalNote")}
value={(cfg as { note?: string }).note ?? ""}
onChange={(v) => onUpdateConfig({ note: v })}
rows={2}
@ -201,8 +208,7 @@ export function NodeConfigForm({
case "end":
return (
<p className="text-xs text-muted-foreground">
Terminal node. When the runner reaches this node the run is marked
complete. No config needed.
{t("endNodeHelp")}
</p>
);
}
@ -224,12 +230,14 @@ function SendButtonsForm({
currentKey,
onUpdateConfig,
showAdvanced,
t,
}: {
cfg: SendButtonsCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) {
const buttons = cfg.buttons ?? [];
const updateButton = (
@ -257,20 +265,20 @@ function SendButtonsForm({
return (
<>
<TextRow
label="Body text"
label={t("bodyText")}
value={cfg.text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })}
rows={3}
/>
<TextRow
label="Footer (optional, 60 chars)"
label={t("footerText")}
value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs text-muted-foreground">
Buttons (1–3) — each one routes to a different next node
{t("buttonsHelp")}
</label>
</div>
<div className="flex flex-col gap-3">
@ -299,7 +307,7 @@ function SendButtonsForm({
<Input
value={b.title}
onChange={(e) => updateButton(i, { title: e.target.value })}
placeholder="Visible title (≤20 chars)"
placeholder={t("optionTitlePlaceholder")}
className="bg-muted"
maxLength={20}
/>
@ -308,7 +316,7 @@ function SendButtonsForm({
nodes={allNodes}
excludeKey={currentKey}
onChange={(v) => updateButton(i, { next_node_key: v ?? "" })}
placeholder="Next node…"
placeholder={t("nextNodePlaceholder")}
/>
<Button
variant="ghost"
@ -329,7 +337,7 @@ function SendButtonsForm({
className="mt-2"
>
<Plus className="h-3.5 w-3.5" />
Add button
{t("addButton")}
</Button>
)}
</div>
@ -362,12 +370,14 @@ function SendListForm({
currentKey,
onUpdateConfig,
showAdvanced,
t,
}: {
cfg: SendListCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) {
const sections = cfg.sections ?? [];
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
@ -453,12 +463,12 @@ function SendListForm({
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextRow
label="Tap-to-expand button label (≤20 chars)"
label={t("buttonLabel")}
value={cfg.button_label ?? ""}
onChange={(v) => onUpdateConfig({ button_label: v })}
/>
<TextRow
label="Footer (optional, 60 chars)"
label={t("footerText")}
value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })}
/>
@ -466,7 +476,7 @@ function SendListForm({
<div className="mt-2">
<label className="mb-2 block text-xs text-muted-foreground">
Rows (1–10 total across all sections)
{t("rowsHelp")}
</label>
{sections.map((section, sIdx) => (
<div
@ -479,7 +489,7 @@ function SendListForm({
onChange={(e) =>
updateSection(sIdx, { title: e.target.value })
}
placeholder={`Section ${sIdx + 1} title (optional)`}
placeholder={t("sectionTitlePlaceholder", { count: sIdx + 1 })}
className="bg-muted text-xs"
/>
{sections.length > 1 && (
@ -524,7 +534,7 @@ function SendListForm({
onChange={(e) =>
updateRow(sIdx, rIdx, { title: e.target.value })
}
placeholder="Row title (≤24)"
placeholder={t("rowTitlePlaceholder")}
className="bg-muted"
maxLength={24}
/>
@ -535,7 +545,7 @@ function SendListForm({
onChange={(v) =>
updateRow(sIdx, rIdx, { next_node_key: v ?? "" })
}
placeholder="Next node…"
placeholder={t("nextNodePlaceholder")}
/>
<Button
variant="ghost"
@ -555,7 +565,7 @@ function SendListForm({
className="mt-1"
>
<Plus className="h-3.5 w-3.5" />
Add row
{t("addRow")}
</Button>
)}
</div>
@ -566,7 +576,7 @@ function SendListForm({
{sections.length < 10 && (
<Button variant="outline" size="sm" onClick={addSection}>
<Plus className="h-3.5 w-3.5" />
Add section
{t("addSection")}
</Button>
)}
</div>
@ -598,11 +608,13 @@ function ConditionForm({
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: ConditionCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const tags = useUserTags();
@ -614,7 +626,7 @@ function ConditionForm({
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">If</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("ifLabel")}</label>
<Select
value={subject}
onValueChange={(v) =>
@ -625,19 +637,19 @@ function ConditionForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="var">Captured variable</SelectItem>
<SelectItem value="tag">Contact has tag</SelectItem>
<SelectItem value="contact_field">Contact field</SelectItem>
<SelectItem value="var">{t("capturedVariable")}</SelectItem>
<SelectItem value="tag">{t("contactHasTag")}</SelectItem>
<SelectItem value="contact_field">{t("contactField")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-xs text-muted-foreground">
{subject === "var"
? "var name"
? t("varName")
: subject === "tag"
? "Tag"
: "Field"}
? t("tagLabel")
: t("fieldLabel")}
</label>
{subject === "tag" && tags.length > 0 ? (
<Select
@ -661,7 +673,7 @@ function ConditionForm({
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a field…" />
<SelectValue placeholder={t("pickField")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">name</SelectItem>
@ -676,7 +688,7 @@ function ConditionForm({
onChange={(e) =>
onUpdateConfig({ subject_key: e.target.value })
}
placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
placeholder={subject === "var" ? t("varKeyPlaceholder") : t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs"
/>
)}
@ -690,7 +702,7 @@ function ConditionForm({
)}
>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Operator</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("operatorLabel")}</label>
<Select
value={operator}
onValueChange={(v) =>
@ -701,16 +713,16 @@ function ConditionForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="present">is present</SelectItem>
<SelectItem value="absent">is absent</SelectItem>
<SelectItem value="equals">equals</SelectItem>
<SelectItem value="contains">contains</SelectItem>
<SelectItem value="present">{t("isPresent")}</SelectItem>
<SelectItem value="absent">{t("isAbsent")}</SelectItem>
<SelectItem value="equals">{t("equals")}</SelectItem>
<SelectItem value="contains">{t("contains")}</SelectItem>
</SelectContent>
</Select>
</div>
{showValue && (
<div>
<label className="mb-1 block text-xs text-muted-foreground">Value</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("valueLabel")}</label>
<Input
value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })}
@ -726,14 +738,14 @@ function ConditionForm({
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ true_next: v })}
label="If true → advance to"
label={t("ifTrueAdvance")}
/>
<NextNodeRow
value={cfg.false_next ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ false_next: v })}
label="If false → advance to"
label={t("ifFalseAdvance")}
/>
</div>
</>
@ -755,11 +767,13 @@ function SetTagForm({
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: SetTagCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const tags = useUserTags();
@ -767,7 +781,7 @@ function SetTagForm({
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-muted-foreground">Action</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("actionLabel")}</label>
<Select
value={cfg.mode ?? "add"}
onValueChange={(v) =>
@ -778,13 +792,13 @@ function SetTagForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add">Add tag</SelectItem>
<SelectItem value="remove">Remove tag</SelectItem>
<SelectItem value="add">{t("addTag")}</SelectItem>
<SelectItem value="remove">{t("removeTag")}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Tag</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("tagLabel")}</label>
{tags.length > 0 ? (
<Select
value={cfg.tag_id ?? ""}
@ -805,7 +819,7 @@ function SetTagForm({
<Input
value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder="Tag UUID"
placeholder={t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs"
/>
)}
@ -816,7 +830,7 @@ function SetTagForm({
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Then advance to"
label={t("thenAdvanceTo")}
/>
</>
);
@ -878,11 +892,13 @@ function SendMediaForm({
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: SendMediaCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
@ -930,7 +946,7 @@ function SendMediaForm({
return (
<>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Media type</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("mediaTypeLabel")}</label>
<Select
value={mediaType}
onValueChange={(v) => {
@ -948,17 +964,17 @@ function SendMediaForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">Image (PNG, JPEG, WebP)</SelectItem>
<SelectItem value="video">Video (MP4, 3GP)</SelectItem>
<SelectItem value="image">{t("imageLabel")}</SelectItem>
<SelectItem value="video">{t("videoLabel")}</SelectItem>
<SelectItem value="document">
Document (PDF, Word, Excel, PowerPoint, TXT)
{t("documentLabel")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">File</label>
<label className="mb-1 block text-xs text-muted-foreground">{t("fileLabel")}</label>
{cfg.media_url ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
@ -975,7 +991,7 @@ function SendMediaForm({
type="button"
onClick={handleClear}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Remove file"
aria-label={t("removeFile")}
disabled={uploading}
>
<X className="h-3.5 w-3.5" />
@ -991,12 +1007,12 @@ function SendMediaForm({
{uploading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading…
{t("uploading")}
</>
) : (
<>
<Upload className="h-3.5 w-3.5" />
Click to upload (max 16 MB)
{t("clickToUpload")}
</>
)}
</button>
@ -1016,7 +1032,7 @@ function SendMediaForm({
</div>
<TextRow
label="Caption (optional, shown under the media)"
label={t("captionLabel")}
value={cfg.caption ?? ""}
onChange={(v) => onUpdateConfig({ caption: v })}
rows={2}
@ -1025,12 +1041,12 @@ function SendMediaForm({
{isDocument && (
<div>
<label className="mb-1 block text-xs text-muted-foreground">
Filename shown to the customer (documents only)
{t("filenameLabel")}
</label>
<Input
value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder="invoice.pdf"
placeholder={t("filenamePlaceholder")}
className="bg-muted text-xs"
/>
</div>
@ -1041,7 +1057,7 @@ function SendMediaForm({
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After sending, advance to"
label={t("advanceAfterSending")}
/>
</>
);