diff --git a/messages/en.json b/messages/en.json
index 04405c0..d897391 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1021,6 +1021,8 @@
"deleteNode": "Delete node",
"canvasView": "Canvas",
"listView": "List",
+ "noNodeSelected": "No node selected",
+ "noNodeDesc": "Select a node on the canvas to edit its configuration.",
"form": {
"advancesTo": "Advances to",
"textToCustomer": "Text sent to the customer",
@@ -1127,6 +1129,34 @@
"blurb": "Ends the flow"
}
}
+ },
+ "validation": {
+ "noIssues": "No issues. Ready to activate.",
+ "summary": "{errorCount} {errorCount, plural, =1 {error} other {errors}}, {warningCount} {warningCount, plural, =1 {warning} other {warnings}}",
+ "jumpToNode": "Jump to node {key}"
+ },
+ "editorState": {
+ "saved": "Saved.",
+ "fixIssues": "Fix the issues below before activating.",
+ "statusChanged": "Flow is now {status}."
+ },
+ "summary": {
+ "options": "{count} {count, plural, =1 {option} other {options}}",
+ "optionsAcrossSections": "{rowCount} {rowCount, plural, =1 {option} other {options}} across {sectionCount} {sectionCount, plural, =1 {section} other {sections}}",
+ "noFile": "{label} (no file uploaded)",
+ "media": "Media",
+ "image": "Image",
+ "video": "Video",
+ "document": "Document",
+ "audio": "Audio",
+ "hasTag": "has tag {tag}",
+ "opContains": "contains",
+ "opExists": "exists",
+ "opMissing": "missing",
+ "modeAdd": "Add",
+ "modeRemove": "Remove",
+ "tagPicked": "{mode} tag {tag}…",
+ "tagNone": "{mode} tag (none picked)"
}
},
"Settings": {
diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx
index bda5a47..66f0528 100644
--- a/src/components/flows/flow-builder.tsx
+++ b/src/components/flows/flow-builder.tsx
@@ -403,7 +403,8 @@ function NodeCard({
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === 'error');
- const preview = summarizeNode(node);
+ const tSummary = useTranslations('Flows.summary');
+ const preview = summarizeNode(node, tSummary);
return (
(() => ({
name: initialFlow.name,
@@ -347,7 +349,7 @@ export function FlowEditorProvider({
throw new Error(json.error ?? `Save failed: ${res.status}`);
}
setDirty(false);
- toast.success("Saved.");
+ toast.success(t("saved"));
} catch (err) {
const msg = err instanceof Error ? err.message : "Save failed";
toast.error(msg);
@@ -360,7 +362,7 @@ export function FlowEditorProvider({
const setStatus = useCallback(
async (next: BuilderState["status"]) => {
if (next === "active" && !canActivate) {
- toast.error("Fix the issues below before activating.");
+ toast.error(t("fixIssues"));
return;
}
setActivating(true);
@@ -381,13 +383,7 @@ export function FlowEditorProvider({
throw new Error(json.error ?? `Status update failed: ${res.status}`);
}
setStateRaw((s) => ({ ...s, status: next }));
- toast.success(
- next === "active"
- ? "Flow activated."
- : next === "archived"
- ? "Archived."
- : "Saved as draft.",
- );
+ toast.success(t("statusChanged", { status: next }));
} catch (err) {
const msg = err instanceof Error ? err.message : "Status update failed";
toast.error(msg);
diff --git a/src/components/flows/shared.tsx b/src/components/flows/shared.tsx
index cab3f97..66a4f96 100644
--- a/src/components/flows/shared.tsx
+++ b/src/components/flows/shared.tsx
@@ -305,7 +305,10 @@ export function truncate(s: string, max = 80): string {
return clean.slice(0, max - 1) + '…';
}
-export function summarizeNode(node: BuilderNode): string | null {
+export function summarizeNode(
+ node: BuilderNode,
+ t?: (key: string, values?: Record
) => string
+): string | null {
const cfg = node.config;
switch (node.node_type) {
case 'start':
@@ -342,11 +345,13 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0);
if (text.length > 0) {
return rowCount > 0
- ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
+ ? `${truncate(text, 50)} · ${t ? t('options', { count: rowCount }) : `${rowCount} option${rowCount === 1 ? '' : 's'}`}`
: truncate(text);
}
return rowCount > 0
- ? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
+ ? t
+ ? t('optionsAcrossSections', { rowCount, sectionCount: sections.length })
+ : `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null;
}
case 'send_media': {
@@ -356,9 +361,9 @@ export function summarizeNode(node: BuilderNode): string | null {
const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType
- ? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
- : 'Media';
- if (!url) return `${label} (no file uploaded)`;
+ ? t ? t(mediaType) || (mediaType.charAt(0).toUpperCase() + mediaType.slice(1)) : mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
+ : t ? t('media') : 'Media';
+ if (!url) return t ? t('noFile', { label }) : `${label} (no file uploaded)`;
const name = filename || url.split('/').pop() || 'file';
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
@@ -386,17 +391,17 @@ export function summarizeNode(node: BuilderNode): string | null {
: 'var';
const subjectStr =
subject === 'tag'
- ? `has tag ${truncate(subjectKey, 24)}`
+ ? t ? t('hasTag', { tag: truncate(subjectKey, 24) }) : `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op =
cfg.operator === 'equals'
? '=='
: cfg.operator === 'contains'
- ? 'contains'
+ ? t ? t('opContains') : 'contains'
: cfg.operator === 'present'
- ? 'exists'
+ ? t ? t('opExists') : 'exists'
: cfg.operator === 'absent'
- ? 'missing'
+ ? t ? t('opMissing') : 'missing'
: '';
const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr =
@@ -406,14 +411,14 @@ export function summarizeNode(node: BuilderNode): string | null {
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case 'set_tag': {
- const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
+ const mode = cfg.mode === 'remove' ? (t ? t('modeRemove') : 'Remove') : (t ? t('modeAdd') : 'Add');
const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance.
return tagId
- ? `${mode} tag ${tagId.slice(0, 8)}…`
- : `${mode} tag (none picked)`;
+ ? t ? t('tagPicked', { mode, tag: tagId.slice(0, 8) }) : `${mode} tag ${tagId.slice(0, 8)}…`
+ : t ? t('tagNone', { mode }) : `${mode} tag (none picked)`;
}
case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : '';
diff --git a/src/components/flows/validation-panel.tsx b/src/components/flows/validation-panel.tsx
index 75812ac..f10c8f1 100644
--- a/src/components/flows/validation-panel.tsx
+++ b/src/components/flows/validation-panel.tsx
@@ -18,12 +18,14 @@
*/
import { CircleAlert, CircleCheck } from "lucide-react";
+import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { ValidationIssue } from "@/lib/flows/validate";
import { useFlowEditor } from "./flow-editor-state";
export function ValidationPanel() {
const { issues, requestFlash } = useFlowEditor();
+ const t = useTranslations("Flows.validation");
if (issues.length === 0) {
// Slate-950 base + emerald accents so the panel stays readable when
@@ -32,7 +34,7 @@ export function ValidationPanel() {
return (
- No issues. Ready to activate.
+ {t("noIssues")}
);
}
@@ -51,12 +53,11 @@ export function ValidationPanel() {
) : (
)}
- {errors.length} error{errors.length === 1 ? "" : "s"},{" "}
- {warnings.length} warning{warnings.length === 1 ? "" : "s"}
+ {t("summary", { errorCount: errors.length, warningCount: warnings.length })}
{issues.map((i, ix) => (
-
+
))}
@@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({
issue,
onJump,
+ t,
}: {
issue: ValidationIssue;
onJump?: (key: string) => void;
+ t?: ReturnType;
}) {
const tone =
issue.severity === "error" ? "text-red-300" : "text-amber-300";
@@ -106,7 +109,7 @@ export function IssueLine({
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
tone,
)}
- aria-label={`Jump to node ${issue.node_key}`}
+ aria-label={t ? t("jumpToNode", { key: issue.node_key! }) : `Jump to node ${issue.node_key}`}
>
{body}