diff --git a/messages/en.json b/messages/en.json index 44be474..9293ee4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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 Start node, then a Send buttons node, then a Handoff — 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" } } } } diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index acf27fe..917fc82 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -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(null); const [nodes, setNodes] = useState([]); @@ -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 (
-

Flow not found.

+

{t("notFound")}

); diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx index 3c43621..87a0d48 100644 --- a/src/app/(dashboard)/flows/[id]/runs/page.tsx +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -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([]); @@ -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 (
-

Flow not found.

+

{tEdit("notFound")}

); @@ -180,16 +184,14 @@ export default function FlowRunsPage() { {flow.name} -

Runs

+

{t("title")}

- The 50 most recent times this flow ran. Expand a row to see the engine's - per-step log. + {t("description")}

{runs.length === 0 ? (
- No runs yet. Trigger the flow from a personal WhatsApp number to see - it appear here. + {t("emptyState")}
) : (
@@ -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} /> ))}
@@ -213,16 +216,18 @@ function RunCard({ events, expanded, onToggle, + t, }: { run: RunRow; events: EventRow[]; expanded: boolean; onToggle: () => void; + t: ReturnType; }) { 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({ - {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" + )} {run.status === "active" && run.current_node_key && ( - at {run.current_node_key} + {t("atNode", { node: run.current_node_key })} )}
- Started {format(new Date(run.started_at), "PP p")} + {t("started", { time: format(new Date(run.started_at), "PP p") })} {run.reprompt_count > 0 && ( - · {run.reprompt_count} re-prompts + · {t("reprompts", { count: run.reprompt_count })} )} - {duration && · ran for {duration}} + {duration && · {t("ranFor", { duration })}}
@@ -269,7 +286,7 @@ function RunCard({ {Object.keys(run.vars).length > 0 && (
- Captured vars ({Object.keys(run.vars).length}) + {t("capturedVars", { count: Object.keys(run.vars).length })}
                 {JSON.stringify(run.vars, null, 2)}
@@ -279,7 +296,7 @@ function RunCard({
           
{events.length === 0 ? (

- No events recorded for this run. + {t("noEvents")}

) : ( events.map((ev, ix) => ) diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 418acb2..0753f5a 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -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 = { - draft: "Draft", - active: "Active", - archived: "Archived", -}; +const STATUS_LABELS = (t: ReturnType): Record => ({ + draft: t("statusDraft"), + active: t("statusActive"), + archived: t("statusArchived"), +}); const STATUS_COLORS: Record = { 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([]); 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() {
-

Flows

+

{t("title")}

- Beta + {t("beta")}

- Build branching, button-driven WhatsApp conversations. Useful for - menus, FAQs, and triage before a human steps in. + {t("description")}

setCreateOpen(true)} > - New flow + {t("newFlow")}
@@ -228,6 +227,7 @@ export default function FlowsPage() { setCreateOpen(true)} canCreate={canCreate} + t={t} /> ) : (
@@ -237,6 +237,7 @@ export default function FlowsPage() { flow={flow} onEdit={() => router.push(`/flows/${flow.id}`)} onDelete={() => handleDelete(flow)} + t={t} /> ))}
@@ -249,37 +250,37 @@ export default function FlowsPage() { sm-scoped 384px wins at every real desktop breakpoint. */} - Create a new flow + {t("createTitle")} - Start from a template or build from scratch. + {t("createDesc")} {templates.length > 0 && (

- Start from a template + {t("startTemplate")}

- {templates.map((t) => { - const Icon = TEMPLATE_ICONS[t.icon] ?? FileText; + {templates.map((template) => { + const Icon = TEMPLATE_ICONS[template.icon] ?? FileText; return ( ); @@ -290,12 +291,12 @@ export default function FlowsPage() {

- Or start blank + {t("startBlank")}

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")} @@ -325,9 +326,11 @@ export default function FlowsPage() { function EmptyState({ onCreate, canCreate, + t, }: { onCreate: () => void; canCreate: boolean; + t: ReturnType; }) { return (
@@ -335,12 +338,10 @@ function EmptyState({

- No flows yet + {t("emptyTitle")}

- 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")}

- Create your first flow + {t("createFirst")}
); @@ -359,12 +360,14 @@ function FlowCard({ flow, onEdit, onDelete, + t, }: { flow: FlowRow; onEdit: () => void; onDelete: () => void; + t: ReturnType; }) { - const triggerSummary = describeTrigger(flow); + const triggerSummary = describeTrigger(flow, t); const StatusIcon = flow.status === "active" ? PlayCircle @@ -388,7 +391,7 @@ function FlowCard({ )} > - {STATUS_LABELS[flow.status]} + {STATUS_LABELS(t)[flow.status]}
@@ -399,14 +402,14 @@ function FlowCard({
- {flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"} + {t("runCount", { count: flow.execution_count })}
); } -function describeTrigger(flow: FlowRow): string { +function describeTrigger(flow: FlowRow, t: ReturnType): 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"); } diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index ae459da..bda5a47 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -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} /> - +

- Nodes ({state.nodes.length}) + {t('nodesTitle', { count: state.nodes.length })}

- +
{state.nodes.length === 0 ? ( -
- Add a Start node, then a{' '} - Send buttons node, then a Handoff{' '} - — that's the welcome-menu shape from the brief. -
+
) : ( state.nodes.map((node) => ( 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; }) { 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>; triggerIssues: ValidationIssue[]; + t: ReturnType; }) { return (
-

Trigger

+

{t('triggerTitle')}

@@ -304,7 +308,7 @@ function TriggerPanel({ {state.trigger_type === 'keyword' && (
)} @@ -340,20 +345,22 @@ function TriggerPanel({ function EntryPicker({ state, setState, + t, }: { state: BuilderState; setState: React.Dispatch>; + t: ReturnType; }) { if (state.nodes.length === 0) return null; return (
- Entry node: + {t('entryNodeTitle')} setState((s) => ({ ...s, entry_node_id: key }))} - placeholder="Pick the first node…" + placeholder={t('entryNodePlaceholder')} className="max-w-xs flex-1" />
@@ -377,6 +384,7 @@ function NodeCard({ onUpdateConfig, onRemove, onSetEntry, + t, }: { node: BuilderNode; allNodes: BuilderNode[]; @@ -390,6 +398,7 @@ function NodeCard({ onUpdateConfig: (patch: Record) => void; onRemove: () => void; onSetEntry: () => void; + t: ReturnType; }) { 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`)} {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')} )}
@@ -461,12 +470,13 @@ function NodeCard({ allNodes={allNodes} onUpdate={onUpdate} onUpdateConfig={onUpdateConfig} + t={t} />
{!isEntry && ( )}
@@ -477,7 +487,7 @@ function NodeCard({ className="text-red-400 hover:bg-red-500/10 hover:text-red-300" > - Remove node + {t('removeNode')}
{issues.length > 0 && ( @@ -504,11 +514,13 @@ function NodeConfigWithAdvanced({ allNodes, onUpdate, onUpdateConfig, + t, }: { node: BuilderNode; allNodes: BuilderNode[]; onUpdate: (patch: Partial) => void; onUpdateConfig: (patch: Record) => void; + t: ReturnType; }) { const [showAdvanced, setShowAdvanced] = useState(false); const hasReplyIds = @@ -532,13 +544,13 @@ function NodeConfigWithAdvanced({ ) : ( )} - {showAdvanced ? 'Hide' : 'Show'} advanced + {showAdvanced ? t('hideAdvanced') : t('showAdvanced')} {showAdvanced && (
{hasReplyIds && (

- 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. + {t('replyIdsHint')}

)}
@@ -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 }) { const types: NodeType[] = [ 'start', 'send_buttons', @@ -583,10 +593,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { - Add node + {t('addNode')} {groupNodeTypesByCategory(types).map((group, i) => ( @@ -598,14 +608,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { {i > 0 && } - {group.label} + {t(`categories.${group.id}`)} - {group.types.map((t) => { - const meta = NODE_META[t]; + {group.types.map((t_type) => { + const meta = NODE_META[t_type]; return ( - onAdd(t)}> + onAdd(t_type)}> - {meta.label} + {t(`nodes.${t_type}.label`)} ); })} diff --git a/src/components/flows/flow-canvas.tsx b/src/components/flows/flow-canvas.tsx index 10e668b..9747e3f 100644 --- a/src/components/flows/flow-canvas.tsx +++ b/src/components/flows/flow-canvas.tsx @@ -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`)} {isEntry && ( - Entry + {t('badgeEntry')} )}
@@ -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 (
-

No nodes yet.

- +

{t('noNodesYet')}

+
); } @@ -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)]" /> - +
@@ -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) => void; onDelete: () => void; onSetEntry: () => void; + t: ReturnType; }) { // 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({
- {meta.label} + {t(`nodes.${node.node_type}.label`)} {isEntry && ( - Entry + {t('badgeEntry')} )} - {meta.blurb} + {t(`nodes.${node.node_type}.blurb`)}
@@ -656,7 +663,7 @@ function NodeEditSheet({ {!isEntry ? ( ) : ( @@ -668,7 +675,7 @@ function NodeEditSheet({ className="text-red-400 hover:bg-red-500/10 hover:text-red-300" > - Delete node + {t('deleteNode')} @@ -696,7 +703,7 @@ const ADD_NODE_TYPES: NodeType[] = [ 'end', ]; -function CanvasAddNodeButton() { +function CanvasAddNodeButton({ t }: { t: ReturnType }) { const reactFlow = useReactFlow(); const { addNode, updateNodePosition } = useFlowEditor(); @@ -728,10 +735,10 @@ function CanvasAddNodeButton() { - Add node + {t('addNode')} 0 && } - {group.label} + {t(`categories.${group.id}`)} - {group.types.map((t) => { - const meta = NODE_META[t]; + {group.types.map((t_type) => { + const meta = NODE_META[t_type]; return ( handleAdd(t)} + key={t_type} + onClick={() => handleAdd(t_type)} className="gap-3 py-2" > - {meta.label} + {t(`nodes.${t_type}.label`)} - {meta.blurb} + {t(`nodes.${t_type}.blurb`)} diff --git a/src/components/flows/flow-editor-shell.tsx b/src/components/flows/flow-editor-shell.tsx index 73a005f..384417d 100644 --- a/src/components/flows/flow-editor-shell.tsx +++ b/src/components/flows/flow-editor-shell.tsx @@ -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={} - label="Canvas" + label={t("canvasView")} /> choose("list")} icon={} - label="List" + label={t("listView")} />
- {LEGEND_TYPES.map((t) => ( + {LEGEND_TYPES.map((t_type) => ( - {NODE_META[t].label} + {t(`nodes.${t_type}.label`)} ))}
diff --git a/src/components/flows/forms/fields.tsx b/src/components/flows/forms/fields.tsx index f404185..f43294c 100644 --- a/src/components/flows/forms/fields.tsx +++ b/src/components/flows/forms/fields.tsx @@ -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")} />
); @@ -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 (

- Interpolate in downstream prompts and handoff notes with{" "} + {t("varKeyHelp")}{" "} {"{{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 ( onUpdateConfig({ note: v })} rows={2} @@ -201,8 +208,7 @@ export function NodeConfigForm({ case "end": return (

- Terminal node. When the runner reaches this node the run is marked - complete. No config needed. + {t("endNodeHelp")}

); } @@ -224,12 +230,14 @@ function SendButtonsForm({ currentKey, onUpdateConfig, showAdvanced, + t, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; + t: ReturnType; }) { const buttons = cfg.buttons ?? []; const updateButton = ( @@ -257,20 +265,20 @@ function SendButtonsForm({ return ( <> onUpdateConfig({ text: v })} rows={3} /> onUpdateConfig({ footer_text: v })} />
@@ -299,7 +307,7 @@ function SendButtonsForm({ 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")} /> )}
@@ -362,12 +370,14 @@ function SendListForm({ currentKey, onUpdateConfig, showAdvanced, + t, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; + t: ReturnType; }) { const sections = cfg.sections ?? []; const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); @@ -453,12 +463,12 @@ function SendListForm({ />
onUpdateConfig({ button_label: v })} /> onUpdateConfig({ footer_text: v })} /> @@ -466,7 +476,7 @@ function SendListForm({
{sections.map((section, sIdx) => (
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")} /> )}
@@ -566,7 +576,7 @@ function SendListForm({ {sections.length < 10 && ( )}
@@ -598,11 +608,13 @@ function ConditionForm({ allNodes, currentKey, onUpdateConfig, + t, }: { cfg: ConditionCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + t: ReturnType; }) { const tags = useUserTags(); @@ -614,7 +626,7 @@ function ConditionForm({ <>
- +
{subject === "tag" && tags.length > 0 ? ( @@ -701,16 +713,16 @@ function ConditionForm({ - is present - is absent - equals - contains + {t("isPresent")} + {t("isAbsent")} + {t("equals")} + {t("contains")}
{showValue && (
- + 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")} /> onUpdateConfig({ false_next: v })} - label="If false → advance to" + label={t("ifFalseAdvance")} />
@@ -755,11 +767,13 @@ function SetTagForm({ allNodes, currentKey, onUpdateConfig, + t, }: { cfg: SetTagCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + t: ReturnType; }) { const tags = useUserTags(); @@ -767,7 +781,7 @@ function SetTagForm({ <>
- +
- + {tags.length > 0 ? ( { @@ -948,17 +964,17 @@ function SendMediaForm({ - Image (PNG, JPEG, WebP) - Video (MP4, 3GP) + {t("imageLabel")} + {t("videoLabel")} - Document (PDF, Word, Excel, PowerPoint, TXT) + {t("documentLabel")}
- + {cfg.media_url ? (
@@ -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} > @@ -991,12 +1007,12 @@ function SendMediaForm({ {uploading ? ( <> - Uploading… + {t("uploading")} ) : ( <> - Click to upload (max 16 MB) + {t("clickToUpload")} )} @@ -1016,7 +1032,7 @@ function SendMediaForm({
onUpdateConfig({ caption: v })} rows={2} @@ -1025,12 +1041,12 @@ function SendMediaForm({ {isDocument && (
onUpdateConfig({ filename: e.target.value })} - placeholder="invoice.pdf" + placeholder={t("filenamePlaceholder")} className="bg-muted text-xs" />
@@ -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")} /> );