mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): complete internationalization of flow builder components
This commit is contained in:
parent
d518d54520
commit
caf0f3660e
163
messages/en.json
163
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 <strong>Start</strong> node, then a <strong>Send buttons</strong> node, then a <strong>Handoff</strong> — 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<FlowRow | null>(null);
|
||||
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
|
||||
@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">Flow not found.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="text-sm text-primary hover:opacity-80"
|
||||
>
|
||||
← Back to flows
|
||||
{t("backToFlows")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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<RunRow[]>([]);
|
||||
@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">Flow not found.</p>
|
||||
<p className="text-sm text-muted-foreground">{tEdit("notFound")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="text-sm text-primary hover:opacity-80"
|
||||
>
|
||||
← Back to flows
|
||||
{tEdit("backToFlows")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
{flow.name}
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The 50 most recent times this flow ran. Expand a row to see the engine's
|
||||
per-step log.
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{runs.length === 0 ? (
|
||||
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
No runs yet. Trigger the flow from a personal WhatsApp number to see
|
||||
it appear here.
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -213,16 +216,18 @@ function RunCard({
|
||||
events,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
run: RunRow;
|
||||
events: EventRow[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
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({
|
||||
</span>
|
||||
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{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"
|
||||
)}
|
||||
</Badge>
|
||||
{run.status === "active" && run.current_node_key && (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
at {run.current_node_key}
|
||||
{t("atNode", { node: run.current_node_key })}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Started {format(new Date(run.started_at), "PP p")}</span>
|
||||
<span>{t("started", { time: format(new Date(run.started_at), "PP p") })}</span>
|
||||
{run.reprompt_count > 0 && (
|
||||
<span>· {run.reprompt_count} re-prompts</span>
|
||||
<span>· {t("reprompts", { count: run.reprompt_count })}</span>
|
||||
)}
|
||||
{duration && <span>· ran for {duration}</span>}
|
||||
{duration && <span>· {t("ranFor", { duration })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -269,7 +286,7 @@ function RunCard({
|
||||
{Object.keys(run.vars).length > 0 && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
Captured vars ({Object.keys(run.vars).length})
|
||||
{t("capturedVars", { count: Object.keys(run.vars).length })}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(run.vars, null, 2)}
|
||||
@ -279,7 +296,7 @@ function RunCard({
|
||||
<div className="flex flex-col gap-1">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No events recorded for this run.
|
||||
{t("noEvents")}
|
||||
</p>
|
||||
) : (
|
||||
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
|
||||
|
||||
@ -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<FlowRow["status"], string> = {
|
||||
draft: "Draft",
|
||||
active: "Active",
|
||||
archived: "Archived",
|
||||
};
|
||||
const STATUS_LABELS = (t: ReturnType<typeof useTranslations>): Record<FlowRow["status"], string> => ({
|
||||
draft: t("statusDraft"),
|
||||
active: t("statusActive"),
|
||||
archived: t("statusArchived"),
|
||||
});
|
||||
|
||||
const STATUS_COLORS: Record<FlowRow["status"], string> = {
|
||||
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<FlowRow[]>([]);
|
||||
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() {
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t("title")}</h1>
|
||||
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
|
||||
Beta
|
||||
{t("beta")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build branching, button-driven WhatsApp conversations. Useful for
|
||||
menus, FAQs, and triage before a human steps in.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<GatedButton
|
||||
@ -220,7 +219,7 @@ export default function FlowsPage() {
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New flow
|
||||
{t("newFlow")}
|
||||
</GatedButton>
|
||||
</header>
|
||||
|
||||
@ -228,6 +227,7 @@ export default function FlowsPage() {
|
||||
<EmptyState
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
canCreate={canCreate}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@ -237,6 +237,7 @@ export default function FlowsPage() {
|
||||
flow={flow}
|
||||
onEdit={() => router.push(`/flows/${flow.id}`)}
|
||||
onDelete={() => handleDelete(flow)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -249,37 +250,37 @@ export default function FlowsPage() {
|
||||
sm-scoped 384px wins at every real desktop breakpoint. */}
|
||||
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new flow</DialogTitle>
|
||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Start from a template or build from scratch.
|
||||
{t("createDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Start from a template
|
||||
{t("startTemplate")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((t) => {
|
||||
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
|
||||
{templates.map((template) => {
|
||||
const Icon = TEMPLATE_ICONS[template.icon] ?? FileText;
|
||||
return (
|
||||
<button
|
||||
key={t.slug}
|
||||
key={template.slug}
|
||||
type="button"
|
||||
onClick={() => handleUseTemplate(t.slug)}
|
||||
onClick={() => handleUseTemplate(template.slug)}
|
||||
disabled={creating}
|
||||
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-semibold text-popover-foreground">
|
||||
{t.name}
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t.description}
|
||||
{template.description}
|
||||
</span>
|
||||
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
|
||||
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
|
||||
{t("nodeCount", { count: template.node_count })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@ -290,12 +291,12 @@ export default function FlowsPage() {
|
||||
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Or start blank
|
||||
{t("startBlank")}
|
||||
</p>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => 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")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
|
||||
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create blank flow
|
||||
{t("createBlank")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@ -325,9 +326,11 @@ export default function FlowsPage() {
|
||||
function EmptyState({
|
||||
onCreate,
|
||||
canCreate,
|
||||
t,
|
||||
}: {
|
||||
onCreate: () => void;
|
||||
canCreate: boolean;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||
@ -335,12 +338,10 @@ function EmptyState({
|
||||
<Workflow className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-medium text-foreground">
|
||||
No flows yet
|
||||
{t("emptyTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
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")}
|
||||
</p>
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
@ -349,7 +350,7 @@ function EmptyState({
|
||||
className="mt-5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create your first flow
|
||||
{t("createFirst")}
|
||||
</GatedButton>
|
||||
</div>
|
||||
);
|
||||
@ -359,12 +360,14 @@ function FlowCard({
|
||||
flow,
|
||||
onEdit,
|
||||
onDelete,
|
||||
t,
|
||||
}: {
|
||||
flow: FlowRow;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const triggerSummary = describeTrigger(flow);
|
||||
const triggerSummary = describeTrigger(flow, t);
|
||||
const StatusIcon =
|
||||
flow.status === "active"
|
||||
? PlayCircle
|
||||
@ -388,7 +391,7 @@ function FlowCard({
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{STATUS_LABELS[flow.status]}
|
||||
{STATUS_LABELS(t)[flow.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@ -399,14 +402,14 @@ function FlowCard({
|
||||
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
|
||||
{t("runCount", { count: flow.execution_count })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -415,23 +418,23 @@ function FlowCard({
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeTrigger(flow: FlowRow): string {
|
||||
function describeTrigger(flow: FlowRow, t: ReturnType<typeof useTranslations>): 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");
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
<EntryPicker state={state} setState={setState} />
|
||||
<EntryPicker state={state} setState={setState} t={t} />
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-foreground text-sm font-semibold">
|
||||
Nodes ({state.nodes.length})
|
||||
{t('nodesTitle', { count: state.nodes.length })}
|
||||
</h2>
|
||||
<AddNodeButton onAdd={addNode} />
|
||||
<AddNodeButton onAdd={addNode} t={t} />
|
||||
</div>
|
||||
|
||||
{state.nodes.length === 0 ? (
|
||||
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
|
||||
Add a <strong>Start</strong> node, then a{' '}
|
||||
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
|
||||
— that's the welcome-menu shape from the brief.
|
||||
</div>
|
||||
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm" dangerouslySetInnerHTML={{ __html: t.raw('nodesEmpty') }} />
|
||||
) : (
|
||||
state.nodes.map((node) => (
|
||||
<NodeCard
|
||||
@ -196,6 +195,7 @@ export function FlowBuilder() {
|
||||
onSetEntry={() =>
|
||||
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<typeof useTranslations>;
|
||||
}) {
|
||||
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<React.SetStateAction<BuilderState>>;
|
||||
triggerIssues: ValidationIssue[];
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-border bg-card rounded-lg border p-4">
|
||||
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
|
||||
<h2 className="text-foreground mb-3 text-sm font-semibold">{t('triggerTitle')}</h2>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
When…
|
||||
{t('whenLabel')}
|
||||
</label>
|
||||
<Select
|
||||
value={state.trigger_type}
|
||||
@ -290,13 +294,13 @@ function TriggerPanel({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keyword">
|
||||
A message contains a keyword
|
||||
{t('triggerKeywordTitle')}
|
||||
</SelectItem>
|
||||
<SelectItem value="first_inbound_message">
|
||||
Customer's first ever inbound message
|
||||
{t('triggerFirstInboundTitle')}
|
||||
</SelectItem>
|
||||
<SelectItem value="manual">
|
||||
Manual only (no auto-trigger)
|
||||
{t('triggerManualTitle')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -304,7 +308,7 @@ function TriggerPanel({
|
||||
{state.trigger_type === 'keyword' && (
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Keywords (comma-separated)
|
||||
{t('keywordsLabel')}
|
||||
</label>
|
||||
<KeywordsInput
|
||||
keywords={
|
||||
@ -318,6 +322,7 @@ function TriggerPanel({
|
||||
trigger_config: { ...s.trigger_config, keywords },
|
||||
}))
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -340,20 +345,22 @@ function TriggerPanel({
|
||||
function EntryPicker({
|
||||
state,
|
||||
setState,
|
||||
t,
|
||||
}: {
|
||||
state: BuilderState;
|
||||
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
if (state.nodes.length === 0) return null;
|
||||
return (
|
||||
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
|
||||
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">Entry node:</span>
|
||||
<span className="text-muted-foreground text-xs">{t('entryNodeTitle')}</span>
|
||||
<NodeKeySelect
|
||||
value={state.entry_node_id}
|
||||
nodes={state.nodes}
|
||||
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
|
||||
placeholder="Pick the first node…"
|
||||
placeholder={t('entryNodePlaceholder')}
|
||||
className="max-w-xs flex-1"
|
||||
/>
|
||||
</section>
|
||||
@ -377,6 +384,7 @@ function NodeCard({
|
||||
onUpdateConfig,
|
||||
onRemove,
|
||||
onSetEntry,
|
||||
t,
|
||||
}: {
|
||||
node: BuilderNode;
|
||||
allNodes: BuilderNode[];
|
||||
@ -390,6 +398,7 @@ function NodeCard({
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
onRemove: () => void;
|
||||
onSetEntry: () => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
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`)}
|
||||
</span>
|
||||
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
|
||||
{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')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@ -461,12 +470,13 @@ function NodeCard({
|
||||
allNodes={allNodes}
|
||||
onUpdate={onUpdate}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
t={t}
|
||||
/>
|
||||
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEntry && (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
Set as entry
|
||||
{t('setAsEntry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -477,7 +487,7 @@ function NodeCard({
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove node
|
||||
{t('removeNode')}
|
||||
</Button>
|
||||
</div>
|
||||
{issues.length > 0 && (
|
||||
@ -504,11 +514,13 @@ function NodeConfigWithAdvanced({
|
||||
allNodes,
|
||||
onUpdate,
|
||||
onUpdateConfig,
|
||||
t,
|
||||
}: {
|
||||
node: BuilderNode;
|
||||
allNodes: BuilderNode[];
|
||||
onUpdate: (patch: Partial<BuilderNode>) => void;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const hasReplyIds =
|
||||
@ -532,13 +544,13 @@ function NodeConfigWithAdvanced({
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{showAdvanced ? 'Hide' : 'Show'} advanced
|
||||
{showAdvanced ? t('hideAdvanced') : t('showAdvanced')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Node key (internal identifier — keep stable for analytics)
|
||||
{t('nodeKeyLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={node.node_key}
|
||||
@ -550,9 +562,7 @@ function NodeConfigWithAdvanced({
|
||||
</div>
|
||||
{hasReplyIds && (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
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')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -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<typeof useTranslations> }) {
|
||||
const types: NodeType[] = [
|
||||
'start',
|
||||
'send_buttons',
|
||||
@ -583,10 +593,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
aria-label="Add node"
|
||||
aria-label={t('addNode')}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add node
|
||||
{t('addNode')}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
{groupNodeTypesByCategory(types).map((group, i) => (
|
||||
@ -598,14 +608,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
|
||||
{group.label}
|
||||
{t(`categories.${group.id}`)}
|
||||
</DropdownMenuLabel>
|
||||
{group.types.map((t) => {
|
||||
const meta = NODE_META[t];
|
||||
{group.types.map((t_type) => {
|
||||
const meta = NODE_META[t_type];
|
||||
return (
|
||||
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
|
||||
<DropdownMenuItem key={t_type} onClick={() => onAdd(t_type)}>
|
||||
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
|
||||
{meta.label}
|
||||
{t(`nodes.${t_type}.label`)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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`)}
|
||||
</span>
|
||||
{isEntry && (
|
||||
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
|
||||
Entry
|
||||
{t('badgeEntry')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -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 (
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<p>No nodes yet.</p>
|
||||
<CanvasAddNodeButton />
|
||||
<p>{t('noNodesYet')}</p>
|
||||
<CanvasAddNodeButton t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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)]"
|
||||
/>
|
||||
<Panel position="top-left" className="!top-4 !left-4">
|
||||
<CanvasAddNodeButton />
|
||||
<CanvasAddNodeButton t={t} />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
@ -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<string, unknown>) => void;
|
||||
onDelete: () => void;
|
||||
onSetEntry: () => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
// 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({
|
||||
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
|
||||
<span style={{ color: c.text }}>{meta.label}</span>
|
||||
<span style={{ color: c.text }}>{t(`nodes.${node.node_type}.label`)}</span>
|
||||
{isEntry && (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
|
||||
Entry
|
||||
{t('badgeEntry')}
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.blurb}
|
||||
{t(`nodes.${node.node_type}.blurb`)}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
@ -656,7 +663,7 @@ function NodeEditSheet({
|
||||
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
|
||||
{!isEntry ? (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
Set as entry
|
||||
{t('setAsEntry')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@ -668,7 +675,7 @@ function NodeEditSheet({
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete node
|
||||
{t('deleteNode')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@ -696,7 +703,7 @@ const ADD_NODE_TYPES: NodeType[] = [
|
||||
'end',
|
||||
];
|
||||
|
||||
function CanvasAddNodeButton() {
|
||||
function CanvasAddNodeButton({ t }: { t: ReturnType<typeof useTranslations> }) {
|
||||
const reactFlow = useReactFlow();
|
||||
const { addNode, updateNodePosition } = useFlowEditor();
|
||||
|
||||
@ -728,10 +735,10 @@ function CanvasAddNodeButton() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
|
||||
aria-label="Add node"
|
||||
aria-label={t('addNode')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add node
|
||||
{t('addNode')}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
@ -746,28 +753,28 @@ function CanvasAddNodeButton() {
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
|
||||
{group.label}
|
||||
{t(`categories.${group.id}`)}
|
||||
</DropdownMenuLabel>
|
||||
{group.types.map((t) => {
|
||||
const meta = NODE_META[t];
|
||||
{group.types.map((t_type) => {
|
||||
const meta = NODE_META[t_type];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={t}
|
||||
onClick={() => handleAdd(t)}
|
||||
key={t_type}
|
||||
onClick={() => handleAdd(t_type)}
|
||||
className="gap-3 py-2"
|
||||
>
|
||||
<NodeIconChip
|
||||
type={t}
|
||||
type={t_type}
|
||||
size={28}
|
||||
iconSize={16}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-popover-foreground text-[13px] font-semibold">
|
||||
{meta.label}
|
||||
{t(`nodes.${t_type}.label`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11.5px]">
|
||||
{meta.blurb}
|
||||
{t(`nodes.${t_type}.blurb`)}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -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={<GitFork className="h-3.5 w-3.5" />}
|
||||
label="Canvas"
|
||||
label={t("canvasView")}
|
||||
/>
|
||||
<SegButton
|
||||
active={effectiveView === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<List className="h-3.5 w-3.5" />}
|
||||
label="List"
|
||||
label={t("listView")}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
|
||||
{LEGEND_TYPES.map((t) => (
|
||||
{LEGEND_TYPES.map((t_type) => (
|
||||
<span
|
||||
key={t}
|
||||
key={t_type}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: nodeColors(t).solid }}
|
||||
style={{ background: nodeColors(t_type).solid }}
|
||||
/>
|
||||
{NODE_META[t].label}
|
||||
{t(`nodes.${t_type}.label`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -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 (
|
||||
<Select
|
||||
@ -114,7 +116,7 @@ export function NodeKeySelect({
|
||||
<SelectValue placeholder={placeholder ?? "—"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— None —</SelectItem>
|
||||
<SelectItem value="__none__">{t("none")}</SelectItem>
|
||||
{options.map((n) => {
|
||||
const Icon = NODE_META[n.node_type].icon;
|
||||
return (
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -62,6 +63,7 @@ export function NodeConfigForm({
|
||||
showAdvanced,
|
||||
onUpdateConfig,
|
||||
}: NodeConfigFormProps) {
|
||||
const t = useTranslations("Flows.builder.form");
|
||||
const cfg = node.config;
|
||||
switch (node.node_type) {
|
||||
case "start":
|
||||
@ -71,7 +73,7 @@ export function NodeConfigForm({
|
||||
allNodes={allNodes}
|
||||
currentKey={node.node_key}
|
||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||
label="Advances to"
|
||||
label={t("advancesTo")}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -79,7 +81,7 @@ export function NodeConfigForm({
|
||||
return (
|
||||
<>
|
||||
<TextRow
|
||||
label="Text sent to the customer"
|
||||
label={t("textToCustomer")}
|
||||
value={(cfg as { text?: string }).text ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ text: v })}
|
||||
/>
|
||||
@ -88,7 +90,7 @@ export function NodeConfigForm({
|
||||
allNodes={allNodes}
|
||||
currentKey={node.node_key}
|
||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||
label="Advances to"
|
||||
label={t("advancesTo")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@ -101,6 +103,7 @@ export function NodeConfigForm({
|
||||
currentKey={node.node_key}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
showAdvanced={showAdvanced}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -112,6 +115,7 @@ export function NodeConfigForm({
|
||||
currentKey={node.node_key}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
showAdvanced={showAdvanced}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -122,6 +126,7 @@ export function NodeConfigForm({
|
||||
allNodes={allNodes}
|
||||
currentKey={node.node_key}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -129,14 +134,14 @@ export function NodeConfigForm({
|
||||
return (
|
||||
<>
|
||||
<TextRow
|
||||
label="Prompt sent to the customer"
|
||||
label={t("promptToCustomer")}
|
||||
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ prompt_text: v })}
|
||||
rows={2}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
|
||||
{t("varKeyLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={(cfg as { var_key?: string }).var_key ?? ""}
|
||||
@ -145,11 +150,11 @@ export function NodeConfigForm({
|
||||
var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""),
|
||||
})
|
||||
}
|
||||
placeholder="e.g. name, email, company"
|
||||
placeholder={t("varKeyPlaceholder")}
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
Interpolate in downstream prompts and handoff notes with{" "}
|
||||
{t("varKeyHelp")}{" "}
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{{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 (
|
||||
<TextRow
|
||||
label="Internal note (for the agent picking up)"
|
||||
label={t("internalNote")}
|
||||
value={(cfg as { note?: string }).note ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ note: v })}
|
||||
rows={2}
|
||||
@ -201,8 +208,7 @@ export function NodeConfigForm({
|
||||
case "end":
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal node. When the runner reaches this node the run is marked
|
||||
complete. No config needed.
|
||||
{t("endNodeHelp")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@ -224,12 +230,14 @@ function SendButtonsForm({
|
||||
currentKey,
|
||||
onUpdateConfig,
|
||||
showAdvanced,
|
||||
t,
|
||||
}: {
|
||||
cfg: SendButtonsCfg;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
showAdvanced: boolean;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const buttons = cfg.buttons ?? [];
|
||||
const updateButton = (
|
||||
@ -257,20 +265,20 @@ function SendButtonsForm({
|
||||
return (
|
||||
<>
|
||||
<TextRow
|
||||
label="Body text"
|
||||
label={t("bodyText")}
|
||||
value={cfg.text ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ text: v })}
|
||||
rows={3}
|
||||
/>
|
||||
<TextRow
|
||||
label="Footer (optional, 60 chars)"
|
||||
label={t("footerText")}
|
||||
value={cfg.footer_text ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Buttons (1–3) — each one routes to a different next node
|
||||
{t("buttonsHelp")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
@ -299,7 +307,7 @@ function SendButtonsForm({
|
||||
<Input
|
||||
value={b.title}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -329,7 +337,7 @@ function SendButtonsForm({
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add button
|
||||
{t("addButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -362,12 +370,14 @@ function SendListForm({
|
||||
currentKey,
|
||||
onUpdateConfig,
|
||||
showAdvanced,
|
||||
t,
|
||||
}: {
|
||||
cfg: SendListCfg;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
showAdvanced: boolean;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const sections = cfg.sections ?? [];
|
||||
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
|
||||
@ -453,12 +463,12 @@ function SendListForm({
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextRow
|
||||
label="Tap-to-expand button label (≤20 chars)"
|
||||
label={t("buttonLabel")}
|
||||
value={cfg.button_label ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ button_label: v })}
|
||||
/>
|
||||
<TextRow
|
||||
label="Footer (optional, 60 chars)"
|
||||
label={t("footerText")}
|
||||
value={cfg.footer_text ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
||||
/>
|
||||
@ -466,7 +476,7 @@ function SendListForm({
|
||||
|
||||
<div className="mt-2">
|
||||
<label className="mb-2 block text-xs text-muted-foreground">
|
||||
Rows (1–10 total across all sections)
|
||||
{t("rowsHelp")}
|
||||
</label>
|
||||
{sections.map((section, sIdx) => (
|
||||
<div
|
||||
@ -479,7 +489,7 @@ function SendListForm({
|
||||
onChange={(e) =>
|
||||
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")}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -555,7 +565,7 @@ function SendListForm({
|
||||
className="mt-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add row
|
||||
{t("addRow")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -566,7 +576,7 @@ function SendListForm({
|
||||
{sections.length < 10 && (
|
||||
<Button variant="outline" size="sm" onClick={addSection}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add section
|
||||
{t("addSection")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -598,11 +608,13 @@ function ConditionForm({
|
||||
allNodes,
|
||||
currentKey,
|
||||
onUpdateConfig,
|
||||
t,
|
||||
}: {
|
||||
cfg: ConditionCfg;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const tags = useUserTags();
|
||||
|
||||
@ -614,7 +626,7 @@ function ConditionForm({
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">If</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("ifLabel")}</label>
|
||||
<Select
|
||||
value={subject}
|
||||
onValueChange={(v) =>
|
||||
@ -625,19 +637,19 @@ function ConditionForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="var">Captured variable</SelectItem>
|
||||
<SelectItem value="tag">Contact has tag</SelectItem>
|
||||
<SelectItem value="contact_field">Contact field</SelectItem>
|
||||
<SelectItem value="var">{t("capturedVariable")}</SelectItem>
|
||||
<SelectItem value="tag">{t("contactHasTag")}</SelectItem>
|
||||
<SelectItem value="contact_field">{t("contactField")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
{subject === "var"
|
||||
? "var name"
|
||||
? t("varName")
|
||||
: subject === "tag"
|
||||
? "Tag"
|
||||
: "Field"}
|
||||
? t("tagLabel")
|
||||
: t("fieldLabel")}
|
||||
</label>
|
||||
{subject === "tag" && tags.length > 0 ? (
|
||||
<Select
|
||||
@ -661,7 +673,7 @@ function ConditionForm({
|
||||
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue placeholder="Pick a field…" />
|
||||
<SelectValue placeholder={t("pickField")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">name</SelectItem>
|
||||
@ -676,7 +688,7 @@ function ConditionForm({
|
||||
onChange={(e) =>
|
||||
onUpdateConfig({ subject_key: e.target.value })
|
||||
}
|
||||
placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
|
||||
placeholder={subject === "var" ? t("varKeyPlaceholder") : t("tagUuidPlaceholder")}
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
@ -690,7 +702,7 @@ function ConditionForm({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Operator</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("operatorLabel")}</label>
|
||||
<Select
|
||||
value={operator}
|
||||
onValueChange={(v) =>
|
||||
@ -701,16 +713,16 @@ function ConditionForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="present">is present</SelectItem>
|
||||
<SelectItem value="absent">is absent</SelectItem>
|
||||
<SelectItem value="equals">equals</SelectItem>
|
||||
<SelectItem value="contains">contains</SelectItem>
|
||||
<SelectItem value="present">{t("isPresent")}</SelectItem>
|
||||
<SelectItem value="absent">{t("isAbsent")}</SelectItem>
|
||||
<SelectItem value="equals">{t("equals")}</SelectItem>
|
||||
<SelectItem value="contains">{t("contains")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{showValue && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Value</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("valueLabel")}</label>
|
||||
<Input
|
||||
value={cfg.value ?? ""}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
<NextNodeRow
|
||||
value={cfg.false_next ?? ""}
|
||||
allNodes={allNodes}
|
||||
currentKey={currentKey}
|
||||
onChange={(v) => onUpdateConfig({ false_next: v })}
|
||||
label="If false → advance to"
|
||||
label={t("ifFalseAdvance")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@ -755,11 +767,13 @@ function SetTagForm({
|
||||
allNodes,
|
||||
currentKey,
|
||||
onUpdateConfig,
|
||||
t,
|
||||
}: {
|
||||
cfg: SetTagCfg;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const tags = useUserTags();
|
||||
|
||||
@ -767,7 +781,7 @@ function SetTagForm({
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Action</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("actionLabel")}</label>
|
||||
<Select
|
||||
value={cfg.mode ?? "add"}
|
||||
onValueChange={(v) =>
|
||||
@ -778,13 +792,13 @@ function SetTagForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="add">Add tag</SelectItem>
|
||||
<SelectItem value="remove">Remove tag</SelectItem>
|
||||
<SelectItem value="add">{t("addTag")}</SelectItem>
|
||||
<SelectItem value="remove">{t("removeTag")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Tag</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("tagLabel")}</label>
|
||||
{tags.length > 0 ? (
|
||||
<Select
|
||||
value={cfg.tag_id ?? ""}
|
||||
@ -805,7 +819,7 @@ function SetTagForm({
|
||||
<Input
|
||||
value={cfg.tag_id ?? ""}
|
||||
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
|
||||
placeholder="Tag UUID"
|
||||
placeholder={t("tagUuidPlaceholder")}
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
@ -816,7 +830,7 @@ function SetTagForm({
|
||||
allNodes={allNodes}
|
||||
currentKey={currentKey}
|
||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||
label="Then advance to"
|
||||
label={t("thenAdvanceTo")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@ -878,11 +892,13 @@ function SendMediaForm({
|
||||
allNodes,
|
||||
currentKey,
|
||||
onUpdateConfig,
|
||||
t,
|
||||
}: {
|
||||
cfg: SendMediaCfg;
|
||||
allNodes: BuilderNode[];
|
||||
currentKey: string;
|
||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -930,7 +946,7 @@ function SendMediaForm({
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Media type</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("mediaTypeLabel")}</label>
|
||||
<Select
|
||||
value={mediaType}
|
||||
onValueChange={(v) => {
|
||||
@ -948,17 +964,17 @@ function SendMediaForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="image">Image (PNG, JPEG, WebP)</SelectItem>
|
||||
<SelectItem value="video">Video (MP4, 3GP)</SelectItem>
|
||||
<SelectItem value="image">{t("imageLabel")}</SelectItem>
|
||||
<SelectItem value="video">{t("videoLabel")}</SelectItem>
|
||||
<SelectItem value="document">
|
||||
Document (PDF, Word, Excel, PowerPoint, TXT)
|
||||
{t("documentLabel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">File</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("fileLabel")}</label>
|
||||
{cfg.media_url ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
||||
@ -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}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@ -991,12 +1007,12 @@ function SendMediaForm({
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Uploading…
|
||||
{t("uploading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Click to upload (max 16 MB)
|
||||
{t("clickToUpload")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@ -1016,7 +1032,7 @@ function SendMediaForm({
|
||||
</div>
|
||||
|
||||
<TextRow
|
||||
label="Caption (optional, shown under the media)"
|
||||
label={t("captionLabel")}
|
||||
value={cfg.caption ?? ""}
|
||||
onChange={(v) => onUpdateConfig({ caption: v })}
|
||||
rows={2}
|
||||
@ -1025,12 +1041,12 @@ function SendMediaForm({
|
||||
{isDocument && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Filename shown to the customer (documents only)
|
||||
{t("filenameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={cfg.filename ?? ""}
|
||||
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
|
||||
placeholder="invoice.pdf"
|
||||
placeholder={t("filenamePlaceholder")}
|
||||
className="bg-muted text-xs"
|
||||
/>
|
||||
</div>
|
||||
@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user