mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): complete flows module internationalization
This commit is contained in:
parent
7cb019081b
commit
a91b8a7ba7
@ -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": {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 : '';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user