feat(i18n): complete flows module internationalization

This commit is contained in:
Ivan Mercedes 2026-07-07 20:12:38 -04:00
parent 7cb019081b
commit a91b8a7ba7
6 changed files with 65 additions and 29 deletions

View File

@ -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": {

View File

@ -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 (
<div
ref={cardRef}

View File

@ -138,7 +138,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const summary = summarizeNode(node);
const tSummary = useTranslations('Flows.summary');
const summary = summarizeNode(node, tSummary);
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts

View File

@ -49,6 +49,7 @@ import {
validateFlowForActivation,
type ValidationIssue,
} from "@/lib/flows/validate";
import { useTranslations } from "next-intl";
import { unlinkNodeReferences } from "@/lib/flows/edges";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
@ -237,6 +238,7 @@ export function FlowEditorProvider({
children,
}: ProviderProps) {
const router = useRouter();
const t = useTranslations("Flows.editorState");
const [state, setStateRaw] = useState<BuilderState>(() => ({
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);

View File

@ -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 | number>) => 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 : '';

View File

@ -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 (
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300">
<CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate.
{t("noIssues")}
</div>
);
}
@ -51,12 +53,11 @@ export function ValidationPanel() {
) : (
<CircleAlert className="h-4 w-4 text-amber-400" />
)}
{errors.length} error{errors.length === 1 ? "" : "s"},{" "}
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
{t("summary", { errorCount: errors.length, warningCount: warnings.length })}
</div>
<div className="flex flex-col gap-1">
{issues.map((i, ix) => (
<IssueLine key={ix} issue={i} onJump={requestFlash} />
<IssueLine key={ix} issue={i} onJump={requestFlash} t={t} />
))}
</div>
</div>
@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({
issue,
onJump,
t,
}: {
issue: ValidationIssue;
onJump?: (key: string) => void;
t?: ReturnType<typeof useTranslations>;
}) {
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}
</button>