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

View File

@ -17,6 +17,8 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -96,6 +98,8 @@ const STATUS_META: Record<
export default function FlowRunsPage() { export default function FlowRunsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); 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 [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]); const [runs, setRuns] = useState<RunRow[]>([]);
@ -128,7 +132,7 @@ export default function FlowRunsPage() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
toast.error("Couldn't load runs."); toast.error(t("loadError"));
} }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@ -158,13 +162,13 @@ export default function FlowRunsPage() {
if (notFound || !flow) { if (notFound || !flow) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <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 <button
type="button" type="button"
onClick={() => router.push("/flows")} onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80" className="text-sm text-primary hover:opacity-80"
> >
← Back to flows {tEdit("backToFlows")}
</button> </button>
</div> </div>
); );
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
{flow.name} {flow.name}
</button> </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"> <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 {t("description")}
per-step log.
</p> </p>
{runs.length === 0 ? ( {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"> <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 {t("emptyState")}
it appear here.
</div> </div>
) : ( ) : (
<div className="mt-6 flex flex-col gap-2"> <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)} events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)} expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)} onToggle={() => toggle(run.id)}
t={t}
/> />
))} ))}
</div> </div>
@ -213,16 +216,18 @@ function RunCard({
events, events,
expanded, expanded,
onToggle, onToggle,
t,
}: { }: {
run: RunRow; run: RunRow;
events: EventRow[]; events: EventRow[];
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const meta = STATUS_META[run.status]; const meta = STATUS_META[run.status];
const StatusIcon = meta.icon; const StatusIcon = meta.icon;
const contactLabel = 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 const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), { ? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false, addSuffix: false,
@ -247,20 +252,32 @@ function RunCard({
</span> </span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}> <Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" /> <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> </Badge>
{run.status === "active" && run.current_node_key && ( {run.status === "active" && run.current_node_key && (
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <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> </code>
)} )}
</div> </div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> <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 && ( {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>
</div> </div>
</button> </button>
@ -269,7 +286,7 @@ function RunCard({
{Object.keys(run.vars).length > 0 && ( {Object.keys(run.vars).length > 0 && (
<details className="mb-3"> <details className="mb-3">
<summary className="cursor-pointer text-xs text-muted-foreground"> <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> </summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground"> <pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)} {JSON.stringify(run.vars, null, 2)}
@ -279,7 +296,7 @@ function RunCard({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No events recorded for this run. {t("noEvents")}
</p> </p>
) : ( ) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />) events.map((ev, ix) => <EventLine key={ix} ev={ev} />)

View File

@ -18,6 +18,7 @@ import {
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
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";
@ -54,11 +55,11 @@ interface FlowRow {
updated_at: string; updated_at: string;
} }
const STATUS_LABELS: Record<FlowRow["status"], string> = { const STATUS_LABELS = (t: ReturnType<typeof useTranslations>): Record<FlowRow["status"], string> => ({
draft: "Draft", draft: t("statusDraft"),
active: "Active", active: t("statusActive"),
archived: "Archived", archived: t("statusArchived"),
}; });
const STATUS_COLORS: Record<FlowRow["status"], string> = { const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-border bg-muted text-muted-foreground", draft: "border-border bg-muted text-muted-foreground",
@ -84,6 +85,7 @@ const TEMPLATE_ICONS = {
export default function FlowsPage() { export default function FlowsPage() {
const router = useRouter(); const router = useRouter();
const canCreate = useCan("send-messages"); const canCreate = useCan("send-messages");
const t = useTranslations("Flows.list");
const [flows, setFlows] = useState<FlowRow[]>([]); const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -115,7 +117,7 @@ export default function FlowsPage() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
toast.error("Couldn't load flows."); toast.error(t("loadError"));
} }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@ -146,7 +148,7 @@ export default function FlowsPage() {
router.push(`/flows/${json.flow.id}`); router.push(`/flows/${json.flow.id}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Couldn't create flow."); toast.error(t("createError"));
} finally { } finally {
setCreating(false); setCreating(false);
} }
@ -168,7 +170,7 @@ export default function FlowsPage() {
setCreateOpen(false); setCreateOpen(false);
router.push(`/flows/${json.flow.id}`); router.push(`/flows/${json.flow.id}`);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed"; const msg = err instanceof Error ? err.message : t("cloneError");
toast.error(msg); toast.error(msg);
} finally { } finally {
setCreating(false); setCreating(false);
@ -176,18 +178,16 @@ export default function FlowsPage() {
} }
async function handleDelete(flow: FlowRow) { async function handleDelete(flow: FlowRow) {
const yes = window.confirm( const yes = window.confirm(t("deleteConfirm", { name: flow.name }));
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
if (!yes) return; if (!yes) return;
try { try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" }); const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`); if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id)); setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted."); toast.success(t("deleteSuccess"));
} catch (err) { } catch (err) {
console.error(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"> <header className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2"> <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"> <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> </span>
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for {t("description")}
menus, FAQs, and triage before a human steps in.
</p> </p>
</div> </div>
<GatedButton <GatedButton
@ -220,7 +219,7 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New flow {t("newFlow")}
</GatedButton> </GatedButton>
</header> </header>
@ -228,6 +227,7 @@ export default function FlowsPage() {
<EmptyState <EmptyState
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
canCreate={canCreate} canCreate={canCreate}
t={t}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <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} flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)} onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)} onDelete={() => handleDelete(flow)}
t={t}
/> />
))} ))}
</div> </div>
@ -249,37 +250,37 @@ export default function FlowsPage() {
sm-scoped 384px wins at every real desktop breakpoint. */} sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground"> <DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader> <DialogHeader>
<DialogTitle>Create a new flow</DialogTitle> <DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Start from a template or build from scratch. {t("createDesc")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{templates.length > 0 && ( {templates.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template {t("startTemplate")}
</p> </p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => { {templates.map((template) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText; const Icon = TEMPLATE_ICONS[template.icon] ?? FileText;
return ( return (
<button <button
key={t.slug} key={template.slug}
type="button" type="button"
onClick={() => handleUseTemplate(t.slug)} onClick={() => handleUseTemplate(template.slug)}
disabled={creating} 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" 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" /> <Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-popover-foreground"> <span className="text-sm font-semibold text-popover-foreground">
{t.name} {template.name}
</span> </span>
<span className="text-xs leading-relaxed text-muted-foreground"> <span className="text-xs leading-relaxed text-muted-foreground">
{t.description} {template.description}
</span> </span>
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground"> <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> </span>
</button> </button>
); );
@ -290,12 +291,12 @@ export default function FlowsPage() {
<div className="space-y-2 border-t border-border pt-4"> <div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank {t("startBlank")}
</p> </p>
<Input <Input
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu" placeholder={t("placeholderName")}
className="bg-muted" className="bg-muted"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleCreate(); if (e.key === "Enter") handleCreate();
@ -309,11 +310,11 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(false)} onClick={() => setCreateOpen(false)}
disabled={creating} disabled={creating}
> >
Cancel {t("cancel")}
</Button> </Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}> <Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />} {creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create blank flow {t("createBlank")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -325,9 +326,11 @@ export default function FlowsPage() {
function EmptyState({ function EmptyState({
onCreate, onCreate,
canCreate, canCreate,
t,
}: { }: {
onCreate: () => void; onCreate: () => void;
canCreate: boolean; canCreate: boolean;
t: ReturnType<typeof useTranslations>;
}) { }) {
return ( 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"> <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" /> <Workflow className="h-6 w-6 text-muted-foreground" />
</div> </div>
<h2 className="mt-4 text-base font-medium text-foreground"> <h2 className="mt-4 text-base font-medium text-foreground">
No flows yet {t("emptyTitle")}
</h2> </h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground"> <p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation — a welcome menu, an order lookup, an FAQ {t("emptyDesc")}
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
</p> </p>
<GatedButton <GatedButton
canAct={canCreate} canAct={canCreate}
@ -349,7 +350,7 @@ function EmptyState({
className="mt-5" className="mt-5"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create your first flow {t("createFirst")}
</GatedButton> </GatedButton>
</div> </div>
); );
@ -359,12 +360,14 @@ function FlowCard({
flow, flow,
onEdit, onEdit,
onDelete, onDelete,
t,
}: { }: {
flow: FlowRow; flow: FlowRow;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const triggerSummary = describeTrigger(flow); const triggerSummary = describeTrigger(flow, t);
const StatusIcon = const StatusIcon =
flow.status === "active" flow.status === "active"
? PlayCircle ? PlayCircle
@ -388,7 +391,7 @@ function FlowCard({
)} )}
> >
<StatusIcon className="h-3 w-3" /> <StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]} {STATUS_LABELS(t)[flow.status]}
</Badge> </Badge>
</div> </div>
@ -399,14 +402,14 @@ function FlowCard({
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground"> <div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" /> <MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"} {t("runCount", { count: flow.execution_count })}
</span> </span>
</div> </div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3"> <div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}> <Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
Edit {t("edit")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -415,23 +418,23 @@ function FlowCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300" className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete {t("delete")}
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
function describeTrigger(flow: FlowRow): string { function describeTrigger(flow: FlowRow, t: ReturnType<typeof useTranslations>): string {
if (flow.trigger_type === "keyword") { if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords) const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[]) ? (flow.trigger_config.keywords as string[])
: []; : [];
if (keywords.length === 0) return "Triggers on keyword (none set)"; if (keywords.length === 0) return t("triggerKeywordNone");
return `Triggers on: ${keywords.join(", ")}`; return t("triggerKeyword", { keywords: keywords.join(", ") });
} }
if (flow.trigger_type === "first_inbound_message") { 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 { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
CircleAlert, CircleAlert,
Plus, Plus,
@ -73,6 +74,7 @@ import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================ // ============================================================
export function FlowBuilder() { export function FlowBuilder() {
const t = useTranslations('Flows.builder');
const { const {
state, state,
setState, setState,
@ -158,24 +160,21 @@ export function FlowBuilder() {
state={state} state={state}
setState={setState} setState={setState}
triggerIssues={issues.filter((i) => i.scope === 'trigger')} 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"> <section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-semibold"> <h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length}) {t('nodesTitle', { count: state.nodes.length })}
</h2> </h2>
<AddNodeButton onAdd={addNode} /> <AddNodeButton onAdd={addNode} t={t} />
</div> </div>
{state.nodes.length === 0 ? ( {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"> <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') }} />
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>
) : ( ) : (
state.nodes.map((node) => ( state.nodes.map((node) => (
<NodeCard <NodeCard
@ -196,6 +195,7 @@ export function FlowBuilder() {
onSetEntry={() => onSetEntry={() =>
setState((s) => ({ ...s, entry_node_id: node.node_key })) setState((s) => ({ ...s, entry_node_id: node.node_key }))
} }
t={t}
/> />
)) ))
)} )}
@ -221,9 +221,11 @@ export function FlowBuilder() {
function KeywordsInput({ function KeywordsInput({
keywords, keywords,
onChange, onChange,
t,
}: { }: {
keywords: string[]; keywords: string[];
onChange: (keywords: string[]) => void; onChange: (keywords: string[]) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const [draft, setDraft] = useState(keywords.join(', ')); const [draft, setDraft] = useState(keywords.join(', '));
@ -247,7 +249,7 @@ function KeywordsInput({
commit(); commit();
} }
}} }}
placeholder="support, help, hi" placeholder={t('keywordsPlaceholder')}
className="bg-muted" className="bg-muted"
/> />
); );
@ -261,18 +263,20 @@ function TriggerPanel({
state, state,
setState, setState,
triggerIssues, triggerIssues,
t,
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
triggerIssues: ValidationIssue[]; triggerIssues: ValidationIssue[];
t: ReturnType<typeof useTranslations>;
}) { }) {
return ( return (
<section className="border-border bg-card rounded-lg border p-4"> <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 className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
When… {t('whenLabel')}
</label> </label>
<Select <Select
value={state.trigger_type} value={state.trigger_type}
@ -290,13 +294,13 @@ function TriggerPanel({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="keyword"> <SelectItem value="keyword">
A message contains a keyword {t('triggerKeywordTitle')}
</SelectItem> </SelectItem>
<SelectItem value="first_inbound_message"> <SelectItem value="first_inbound_message">
Customer&apos;s first ever inbound message {t('triggerFirstInboundTitle')}
</SelectItem> </SelectItem>
<SelectItem value="manual"> <SelectItem value="manual">
Manual only (no auto-trigger) {t('triggerManualTitle')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -304,7 +308,7 @@ function TriggerPanel({
{state.trigger_type === 'keyword' && ( {state.trigger_type === 'keyword' && (
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated) {t('keywordsLabel')}
</label> </label>
<KeywordsInput <KeywordsInput
keywords={ keywords={
@ -318,6 +322,7 @@ function TriggerPanel({
trigger_config: { ...s.trigger_config, keywords }, trigger_config: { ...s.trigger_config, keywords },
})) }))
} }
t={t}
/> />
</div> </div>
)} )}
@ -340,20 +345,22 @@ function TriggerPanel({
function EntryPicker({ function EntryPicker({
state, state,
setState, setState,
t,
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
t: ReturnType<typeof useTranslations>;
}) { }) {
if (state.nodes.length === 0) return null; if (state.nodes.length === 0) return null;
return ( return (
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3"> <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" /> <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 <NodeKeySelect
value={state.entry_node_id} value={state.entry_node_id}
nodes={state.nodes} nodes={state.nodes}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))} onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…" placeholder={t('entryNodePlaceholder')}
className="max-w-xs flex-1" className="max-w-xs flex-1"
/> />
</section> </section>
@ -377,6 +384,7 @@ function NodeCard({
onUpdateConfig, onUpdateConfig,
onRemove, onRemove,
onSetEntry, onSetEntry,
t,
}: { }: {
node: BuilderNode; node: BuilderNode;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
@ -390,6 +398,7 @@ function NodeCard({
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
onRemove: () => void; onRemove: () => void;
onSetEntry: () => void; onSetEntry: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const meta = NODE_META[node.node_type]; const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type); const c = nodeColors(node.node_type);
@ -425,7 +434,7 @@ function NodeCard({
className="truncate text-[11px] font-semibold tracking-wider uppercase" className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }} style={{ color: c.text }}
> >
{meta.label} {t(`nodes.${node.node_type}.label`)}
</span> </span>
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"> <code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key} {node.node_key}
@ -435,7 +444,7 @@ function NodeCard({
variant="outline" variant="outline"
className="border-primary/40 bg-primary/10 text-primary text-[10px]" className="border-primary/40 bg-primary/10 text-primary text-[10px]"
> >
Entry {t('badgeEntry')}
</Badge> </Badge>
)} )}
</div> </div>
@ -461,12 +470,13 @@ function NodeCard({
allNodes={allNodes} allNodes={allNodes}
onUpdate={onUpdate} onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
t={t}
/> />
<div className="border-border mt-4 flex items-center justify-between border-t pt-3"> <div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isEntry && ( {!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}> <Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry {t('setAsEntry')}
</Button> </Button>
)} )}
</div> </div>
@ -477,7 +487,7 @@ function NodeCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300" className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Remove node {t('removeNode')}
</Button> </Button>
</div> </div>
{issues.length > 0 && ( {issues.length > 0 && (
@ -504,11 +514,13 @@ function NodeConfigWithAdvanced({
allNodes, allNodes,
onUpdate, onUpdate,
onUpdateConfig, onUpdateConfig,
t,
}: { }: {
node: BuilderNode; node: BuilderNode;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
onUpdate: (patch: Partial<BuilderNode>) => void; onUpdate: (patch: Partial<BuilderNode>) => void;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds = const hasReplyIds =
@ -532,13 +544,13 @@ function NodeConfigWithAdvanced({
) : ( ) : (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
)} )}
{showAdvanced ? 'Hide' : 'Show'} advanced {showAdvanced ? t('hideAdvanced') : t('showAdvanced')}
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics) {t('nodeKeyLabel')}
</label> </label>
<Input <Input
value={node.node_key} value={node.node_key}
@ -550,9 +562,7 @@ function NodeConfigWithAdvanced({
</div> </div>
{hasReplyIds && ( {hasReplyIds && (
<p className="text-muted-foreground text-[10px]"> <p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re {t('replyIdsHint')}
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
</p> </p>
)} )}
</div> </div>
@ -566,7 +576,7 @@ function NodeConfigWithAdvanced({
// Add-node menu // 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[] = [ const types: NodeType[] = [
'start', 'start',
'send_buttons', 'send_buttons',
@ -583,10 +593,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <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" 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" /> <Plus className="h-3.5 w-3.5" />
Add node {t('addNode')}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover"> <DropdownMenuContent align="end" className="border-border bg-popover">
{groupNodeTypesByCategory(types).map((group, i) => ( {groupNodeTypesByCategory(types).map((group, i) => (
@ -598,14 +608,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
{i > 0 && <DropdownMenuSeparator />} {i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase"> <DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label} {t(`categories.${group.id}`)}
</DropdownMenuLabel> </DropdownMenuLabel>
{group.types.map((t) => { {group.types.map((t_type) => {
const meta = NODE_META[t]; const meta = NODE_META[t_type];
return ( 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.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label} {t(`nodes.${t_type}.label`)}
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}

View File

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

View File

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

View File

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

View File

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