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", "deleteNode": "Delete node",
"canvasView": "Canvas", "canvasView": "Canvas",
"listView": "List", "listView": "List",
"noNodeSelected": "No node selected",
"noNodeDesc": "Select a node on the canvas to edit its configuration.",
"form": { "form": {
"advancesTo": "Advances to", "advancesTo": "Advances to",
"textToCustomer": "Text sent to the customer", "textToCustomer": "Text sent to the customer",
@ -1127,6 +1129,34 @@
"blurb": "Ends the flow" "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": { "Settings": {

View File

@ -403,7 +403,8 @@ function NodeCard({
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);
const hasError = issues.some((i) => i.severity === 'error'); const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node); const tSummary = useTranslations('Flows.summary');
const preview = summarizeNode(node, tSummary);
return ( return (
<div <div
ref={cardRef} ref={cardRef}

View File

@ -138,7 +138,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
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);
const summary = summarizeNode(node); const tSummary = useTranslations('Flows.summary');
const summary = summarizeNode(node, tSummary);
const slots = outgoingSlots(node); const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they // Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts // don't need an incoming Handle. Every other node type accepts

View File

@ -49,6 +49,7 @@ import {
validateFlowForActivation, validateFlowForActivation,
type ValidationIssue, type ValidationIssue,
} from "@/lib/flows/validate"; } from "@/lib/flows/validate";
import { useTranslations } from "next-intl";
import { unlinkNodeReferences } from "@/lib/flows/edges"; import { unlinkNodeReferences } from "@/lib/flows/edges";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared"; import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
@ -237,6 +238,7 @@ export function FlowEditorProvider({
children, children,
}: ProviderProps) { }: ProviderProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations("Flows.editorState");
const [state, setStateRaw] = useState<BuilderState>(() => ({ const [state, setStateRaw] = useState<BuilderState>(() => ({
name: initialFlow.name, name: initialFlow.name,
@ -347,7 +349,7 @@ export function FlowEditorProvider({
throw new Error(json.error ?? `Save failed: ${res.status}`); throw new Error(json.error ?? `Save failed: ${res.status}`);
} }
setDirty(false); setDirty(false);
toast.success("Saved."); toast.success(t("saved"));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Save failed"; const msg = err instanceof Error ? err.message : "Save failed";
toast.error(msg); toast.error(msg);
@ -360,7 +362,7 @@ export function FlowEditorProvider({
const setStatus = useCallback( const setStatus = useCallback(
async (next: BuilderState["status"]) => { async (next: BuilderState["status"]) => {
if (next === "active" && !canActivate) { if (next === "active" && !canActivate) {
toast.error("Fix the issues below before activating."); toast.error(t("fixIssues"));
return; return;
} }
setActivating(true); setActivating(true);
@ -381,13 +383,7 @@ export function FlowEditorProvider({
throw new Error(json.error ?? `Status update failed: ${res.status}`); throw new Error(json.error ?? `Status update failed: ${res.status}`);
} }
setStateRaw((s) => ({ ...s, status: next })); setStateRaw((s) => ({ ...s, status: next }));
toast.success( toast.success(t("statusChanged", { status: next }));
next === "active"
? "Flow activated."
: next === "archived"
? "Archived."
: "Saved as draft.",
);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Status update failed"; const msg = err instanceof Error ? err.message : "Status update failed";
toast.error(msg); toast.error(msg);

View File

@ -305,7 +305,10 @@ export function truncate(s: string, max = 80): string {
return clean.slice(0, max - 1) + '…'; 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; const cfg = node.config;
switch (node.node_type) { switch (node.node_type) {
case 'start': case 'start':
@ -342,11 +345,13 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0); }, 0);
if (text.length > 0) { if (text.length > 0) {
return rowCount > 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); : truncate(text);
} }
return rowCount > 0 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; : null;
} }
case 'send_media': { 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 url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : ''; const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1) ? t ? t(mediaType) || (mediaType.charAt(0).toUpperCase() + mediaType.slice(1)) : mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: 'Media'; : t ? t('media') : 'Media';
if (!url) return `${label} (no file uploaded)`; if (!url) return t ? t('noFile', { label }) : `${label} (no file uploaded)`;
const name = filename || url.split('/').pop() || 'file'; const name = filename || url.split('/').pop() || 'file';
return caption return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}` ? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
@ -386,17 +391,17 @@ export function summarizeNode(node: BuilderNode): string | null {
: 'var'; : 'var';
const subjectStr = const subjectStr =
subject === 'tag' subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}` ? t ? t('hasTag', { tag: truncate(subjectKey, 24) }) : `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`; : `${subject}.${subjectKey}`;
const op = const op =
cfg.operator === 'equals' cfg.operator === 'equals'
? '==' ? '=='
: cfg.operator === 'contains' : cfg.operator === 'contains'
? 'contains' ? t ? t('opContains') : 'contains'
: cfg.operator === 'present' : cfg.operator === 'present'
? 'exists' ? t ? t('opExists') : 'exists'
: cfg.operator === 'absent' : cfg.operator === 'absent'
? 'missing' ? t ? t('opMissing') : 'missing'
: ''; : '';
const value = typeof cfg.value === 'string' ? cfg.value : ''; const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr = const valStr =
@ -406,14 +411,14 @@ export function summarizeNode(node: BuilderNode): string | null {
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`; return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
} }
case 'set_tag': { 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 : ''; const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a // No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between // short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance. // multiple set_tag nodes at a glance.
return tagId return tagId
? `${mode} tag ${tagId.slice(0, 8)}…` ? t ? t('tagPicked', { mode, tag: tagId.slice(0, 8) }) : `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`; : t ? t('tagNone', { mode }) : `${mode} tag (none picked)`;
} }
case 'handoff': { case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : ''; const note = typeof cfg.note === 'string' ? cfg.note : '';

View File

@ -18,12 +18,14 @@
*/ */
import { CircleAlert, CircleCheck } from "lucide-react"; import { CircleAlert, CircleCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ValidationIssue } from "@/lib/flows/validate"; import type { ValidationIssue } from "@/lib/flows/validate";
import { useFlowEditor } from "./flow-editor-state"; import { useFlowEditor } from "./flow-editor-state";
export function ValidationPanel() { export function ValidationPanel() {
const { issues, requestFlash } = useFlowEditor(); const { issues, requestFlash } = useFlowEditor();
const t = useTranslations("Flows.validation");
if (issues.length === 0) { if (issues.length === 0) {
// Slate-950 base + emerald accents so the panel stays readable when // Slate-950 base + emerald accents so the panel stays readable when
@ -32,7 +34,7 @@ export function ValidationPanel() {
return ( 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"> <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" /> <CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate. {t("noIssues")}
</div> </div>
); );
} }
@ -51,12 +53,11 @@ export function ValidationPanel() {
) : ( ) : (
<CircleAlert className="h-4 w-4 text-amber-400" /> <CircleAlert className="h-4 w-4 text-amber-400" />
)} )}
{errors.length} error{errors.length === 1 ? "" : "s"},{" "} {t("summary", { errorCount: errors.length, warningCount: warnings.length })}
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{issues.map((i, ix) => ( {issues.map((i, ix) => (
<IssueLine key={ix} issue={i} onJump={requestFlash} /> <IssueLine key={ix} issue={i} onJump={requestFlash} t={t} />
))} ))}
</div> </div>
</div> </div>
@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({ export function IssueLine({
issue, issue,
onJump, onJump,
t,
}: { }: {
issue: ValidationIssue; issue: ValidationIssue;
onJump?: (key: string) => void; onJump?: (key: string) => void;
t?: ReturnType<typeof useTranslations>;
}) { }) {
const tone = const tone =
issue.severity === "error" ? "text-red-300" : "text-amber-300"; 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", "flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
tone, 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} {body}
</button> </button>