mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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 }}\"}",
|
"placeholderBody": "{\"id\": \"{{ contact.id }}\"}",
|
||||||
"matchType": "Match type",
|
"matchType": "Match type",
|
||||||
"matchContains": "Contains",
|
"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 { Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
|
import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
|
||||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||||
|
|
||||||
@ -23,6 +25,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
|||||||
export default function FlowEditorPage() {
|
export default function FlowEditorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const t = useTranslations("Flows.edit");
|
||||||
|
|
||||||
const [flow, setFlow] = useState<FlowRow | null>(null);
|
const [flow, setFlow] = useState<FlowRow | null>(null);
|
||||||
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
|
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
|
||||||
@ -51,7 +54,7 @@ export default function FlowEditorPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("Couldn't load flow.");
|
toast.error(t("loadError"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@ -72,13 +75,13 @@ export default function FlowEditorPage() {
|
|||||||
if (notFound || !flow) {
|
if (notFound || !flow) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/flows")}
|
onClick={() => router.push("/flows")}
|
||||||
className="text-sm text-primary hover:opacity-80"
|
className="text-sm text-primary hover:opacity-80"
|
||||||
>
|
>
|
||||||
← Back to flows
|
{t("backToFlows")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -96,6 +98,8 @@ const STATUS_META: Record<
|
|||||||
export default function FlowRunsPage() {
|
export default function FlowRunsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
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 [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [runs, setRuns] = useState<RunRow[]>([]);
|
const [runs, setRuns] = useState<RunRow[]>([]);
|
||||||
@ -128,7 +132,7 @@ export default function FlowRunsPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("Couldn't load runs.");
|
toast.error(t("loadError"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@ -158,13 +162,13 @@ export default function FlowRunsPage() {
|
|||||||
if (notFound || !flow) {
|
if (notFound || !flow) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/flows")}
|
onClick={() => router.push("/flows")}
|
||||||
className="text-sm text-primary hover:opacity-80"
|
className="text-sm text-primary hover:opacity-80"
|
||||||
>
|
>
|
||||||
← Back to flows
|
{tEdit("backToFlows")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
|
|||||||
<ArrowLeft className="h-3 w-3" />
|
<ArrowLeft className="h-3 w-3" />
|
||||||
{flow.name}
|
{flow.name}
|
||||||
</button>
|
</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">
|
<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
|
{t("description")}
|
||||||
per-step log.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{runs.length === 0 ? (
|
{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">
|
<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
|
{t("emptyState")}
|
||||||
it appear here.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<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)}
|
events={events.filter((e) => e.flow_run_id === run.id)}
|
||||||
expanded={expanded.has(run.id)}
|
expanded={expanded.has(run.id)}
|
||||||
onToggle={() => toggle(run.id)}
|
onToggle={() => toggle(run.id)}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -213,16 +216,18 @@ function RunCard({
|
|||||||
events,
|
events,
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
run: RunRow;
|
run: RunRow;
|
||||||
events: EventRow[];
|
events: EventRow[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const meta = STATUS_META[run.status];
|
const meta = STATUS_META[run.status];
|
||||||
const StatusIcon = meta.icon;
|
const StatusIcon = meta.icon;
|
||||||
const contactLabel =
|
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
|
const duration = run.ended_at
|
||||||
? formatDistanceToNow(new Date(run.ended_at), {
|
? formatDistanceToNow(new Date(run.ended_at), {
|
||||||
addSuffix: false,
|
addSuffix: false,
|
||||||
@ -247,20 +252,32 @@ function RunCard({
|
|||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
|
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
|
||||||
<StatusIcon className="h-3 w-3" />
|
<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>
|
</Badge>
|
||||||
{run.status === "active" && run.current_node_key && (
|
{run.status === "active" && run.current_node_key && (
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
<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>
|
</code>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
<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 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -269,7 +286,7 @@ function RunCard({
|
|||||||
{Object.keys(run.vars).length > 0 && (
|
{Object.keys(run.vars).length > 0 && (
|
||||||
<details className="mb-3">
|
<details className="mb-3">
|
||||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
<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>
|
</summary>
|
||||||
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
|
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
|
||||||
{JSON.stringify(run.vars, null, 2)}
|
{JSON.stringify(run.vars, null, 2)}
|
||||||
@ -279,7 +296,7 @@ function RunCard({
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No events recorded for this run.
|
{t("noEvents")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
|
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useCan } from "@/hooks/use-can";
|
import { useCan } from "@/hooks/use-can";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { GatedButton } from "@/components/ui/gated-button";
|
import { GatedButton } from "@/components/ui/gated-button";
|
||||||
@ -54,11 +55,11 @@ interface FlowRow {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<FlowRow["status"], string> = {
|
const STATUS_LABELS = (t: ReturnType<typeof useTranslations>): Record<FlowRow["status"], string> => ({
|
||||||
draft: "Draft",
|
draft: t("statusDraft"),
|
||||||
active: "Active",
|
active: t("statusActive"),
|
||||||
archived: "Archived",
|
archived: t("statusArchived"),
|
||||||
};
|
});
|
||||||
|
|
||||||
const STATUS_COLORS: Record<FlowRow["status"], string> = {
|
const STATUS_COLORS: Record<FlowRow["status"], string> = {
|
||||||
draft: "border-border bg-muted text-muted-foreground",
|
draft: "border-border bg-muted text-muted-foreground",
|
||||||
@ -84,6 +85,7 @@ const TEMPLATE_ICONS = {
|
|||||||
export default function FlowsPage() {
|
export default function FlowsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canCreate = useCan("send-messages");
|
const canCreate = useCan("send-messages");
|
||||||
|
const t = useTranslations("Flows.list");
|
||||||
const [flows, setFlows] = useState<FlowRow[]>([]);
|
const [flows, setFlows] = useState<FlowRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@ -115,7 +117,7 @@ export default function FlowsPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("Couldn't load flows.");
|
toast.error(t("loadError"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@ -146,7 +148,7 @@ export default function FlowsPage() {
|
|||||||
router.push(`/flows/${json.flow.id}`);
|
router.push(`/flows/${json.flow.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error("Couldn't create flow.");
|
toast.error(t("createError"));
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
@ -168,7 +170,7 @@ export default function FlowsPage() {
|
|||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
router.push(`/flows/${json.flow.id}`);
|
router.push(`/flows/${json.flow.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "Clone failed";
|
const msg = err instanceof Error ? err.message : t("cloneError");
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
@ -176,18 +178,16 @@ export default function FlowsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(flow: FlowRow) {
|
async function handleDelete(flow: FlowRow) {
|
||||||
const yes = window.confirm(
|
const yes = window.confirm(t("deleteConfirm", { name: flow.name }));
|
||||||
`Delete "${flow.name}"? Any active runs will end immediately.`,
|
|
||||||
);
|
|
||||||
if (!yes) return;
|
if (!yes) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
|
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
|
||||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||||
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
|
setFlows((prev) => prev.filter((f) => f.id !== flow.id));
|
||||||
toast.success("Flow deleted.");
|
toast.success(t("deleteSuccess"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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">
|
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Build branching, button-driven WhatsApp conversations. Useful for
|
{t("description")}
|
||||||
menus, FAQs, and triage before a human steps in.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<GatedButton
|
<GatedButton
|
||||||
@ -220,7 +219,7 @@ export default function FlowsPage() {
|
|||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New flow
|
{t("newFlow")}
|
||||||
</GatedButton>
|
</GatedButton>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -228,6 +227,7 @@ export default function FlowsPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
onCreate={() => setCreateOpen(true)}
|
onCreate={() => setCreateOpen(true)}
|
||||||
canCreate={canCreate}
|
canCreate={canCreate}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<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}
|
flow={flow}
|
||||||
onEdit={() => router.push(`/flows/${flow.id}`)}
|
onEdit={() => router.push(`/flows/${flow.id}`)}
|
||||||
onDelete={() => handleDelete(flow)}
|
onDelete={() => handleDelete(flow)}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -249,37 +250,37 @@ export default function FlowsPage() {
|
|||||||
sm-scoped 384px wins at every real desktop breakpoint. */}
|
sm-scoped 384px wins at every real desktop breakpoint. */}
|
||||||
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
|
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create a new flow</DialogTitle>
|
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Start from a template or build from scratch.
|
{t("createDesc")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{templates.length > 0 && (
|
{templates.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Start from a template
|
{t("startTemplate")}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{templates.map((t) => {
|
{templates.map((template) => {
|
||||||
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText;
|
const Icon = TEMPLATE_ICONS[template.icon] ?? FileText;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.slug}
|
key={template.slug}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleUseTemplate(t.slug)}
|
onClick={() => handleUseTemplate(template.slug)}
|
||||||
disabled={creating}
|
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"
|
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" />
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
<span className="text-sm font-semibold text-popover-foreground">
|
<span className="text-sm font-semibold text-popover-foreground">
|
||||||
{t.name}
|
{template.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
{t.description}
|
{template.description}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -290,12 +291,12 @@ export default function FlowsPage() {
|
|||||||
|
|
||||||
<div className="space-y-2 border-t border-border pt-4">
|
<div className="space-y-2 border-t border-border pt-4">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
Or start blank
|
{t("startBlank")}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="e.g. Welcome menu"
|
placeholder={t("placeholderName")}
|
||||||
className="bg-muted"
|
className="bg-muted"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleCreate();
|
if (e.key === "Enter") handleCreate();
|
||||||
@ -309,11 +310,11 @@ export default function FlowsPage() {
|
|||||||
onClick={() => setCreateOpen(false)}
|
onClick={() => setCreateOpen(false)}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
|
<Button onClick={handleCreate} disabled={!newName.trim() || creating}>
|
||||||
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
|
{creating && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Create blank flow
|
{t("createBlank")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -325,9 +326,11 @@ export default function FlowsPage() {
|
|||||||
function EmptyState({
|
function EmptyState({
|
||||||
onCreate,
|
onCreate,
|
||||||
canCreate,
|
canCreate,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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" />
|
<Workflow className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-4 text-base font-medium text-foreground">
|
<h2 className="mt-4 text-base font-medium text-foreground">
|
||||||
No flows yet
|
{t("emptyTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||||
Build your first conversation — a welcome menu, an order lookup, an FAQ
|
{t("emptyDesc")}
|
||||||
bot. Customers tap buttons; the bot routes them to the right answer (or
|
|
||||||
the right agent).
|
|
||||||
</p>
|
</p>
|
||||||
<GatedButton
|
<GatedButton
|
||||||
canAct={canCreate}
|
canAct={canCreate}
|
||||||
@ -349,7 +350,7 @@ function EmptyState({
|
|||||||
className="mt-5"
|
className="mt-5"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create your first flow
|
{t("createFirst")}
|
||||||
</GatedButton>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -359,12 +360,14 @@ function FlowCard({
|
|||||||
flow,
|
flow,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
flow: FlowRow;
|
flow: FlowRow;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const triggerSummary = describeTrigger(flow);
|
const triggerSummary = describeTrigger(flow, t);
|
||||||
const StatusIcon =
|
const StatusIcon =
|
||||||
flow.status === "active"
|
flow.status === "active"
|
||||||
? PlayCircle
|
? PlayCircle
|
||||||
@ -388,7 +391,7 @@ function FlowCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusIcon className="h-3 w-3" />
|
<StatusIcon className="h-3 w-3" />
|
||||||
{STATUS_LABELS[flow.status]}
|
{STATUS_LABELS(t)[flow.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -399,14 +402,14 @@ function FlowCard({
|
|||||||
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
|
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<MessageSquare className="h-3 w-3" />
|
<MessageSquare className="h-3 w-3" />
|
||||||
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
|
{t("runCount", { count: flow.execution_count })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit
|
{t("edit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -415,23 +418,23 @@ function FlowCard({
|
|||||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Delete
|
{t("delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeTrigger(flow: FlowRow): string {
|
function describeTrigger(flow: FlowRow, t: ReturnType<typeof useTranslations>): string {
|
||||||
if (flow.trigger_type === "keyword") {
|
if (flow.trigger_type === "keyword") {
|
||||||
const keywords = Array.isArray(flow.trigger_config.keywords)
|
const keywords = Array.isArray(flow.trigger_config.keywords)
|
||||||
? (flow.trigger_config.keywords as string[])
|
? (flow.trigger_config.keywords as string[])
|
||||||
: [];
|
: [];
|
||||||
if (keywords.length === 0) return "Triggers on keyword (none set)";
|
if (keywords.length === 0) return t("triggerKeywordNone");
|
||||||
return `Triggers on: ${keywords.join(", ")}`;
|
return t("triggerKeyword", { keywords: keywords.join(", ") });
|
||||||
}
|
}
|
||||||
if (flow.trigger_type === "first_inbound_message") {
|
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 { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Plus,
|
Plus,
|
||||||
@ -73,6 +74,7 @@ import { useFlowEditor, type BuilderState } from './flow-editor-state';
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export function FlowBuilder() {
|
export function FlowBuilder() {
|
||||||
|
const t = useTranslations('Flows.builder');
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
@ -158,24 +160,21 @@ export function FlowBuilder() {
|
|||||||
state={state}
|
state={state}
|
||||||
setState={setState}
|
setState={setState}
|
||||||
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
|
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">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-foreground text-sm font-semibold">
|
<h2 className="text-foreground text-sm font-semibold">
|
||||||
Nodes ({state.nodes.length})
|
{t('nodesTitle', { count: state.nodes.length })}
|
||||||
</h2>
|
</h2>
|
||||||
<AddNodeButton onAdd={addNode} />
|
<AddNodeButton onAdd={addNode} t={t} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.nodes.length === 0 ? (
|
{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">
|
<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') }} />
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
state.nodes.map((node) => (
|
state.nodes.map((node) => (
|
||||||
<NodeCard
|
<NodeCard
|
||||||
@ -196,6 +195,7 @@ export function FlowBuilder() {
|
|||||||
onSetEntry={() =>
|
onSetEntry={() =>
|
||||||
setState((s) => ({ ...s, entry_node_id: node.node_key }))
|
setState((s) => ({ ...s, entry_node_id: node.node_key }))
|
||||||
}
|
}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -221,9 +221,11 @@ export function FlowBuilder() {
|
|||||||
function KeywordsInput({
|
function KeywordsInput({
|
||||||
keywords,
|
keywords,
|
||||||
onChange,
|
onChange,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
onChange: (keywords: string[]) => void;
|
onChange: (keywords: string[]) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(keywords.join(', '));
|
const [draft, setDraft] = useState(keywords.join(', '));
|
||||||
|
|
||||||
@ -247,7 +249,7 @@ function KeywordsInput({
|
|||||||
commit();
|
commit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="support, help, hi"
|
placeholder={t('keywordsPlaceholder')}
|
||||||
className="bg-muted"
|
className="bg-muted"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -261,18 +263,20 @@ function TriggerPanel({
|
|||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
triggerIssues,
|
triggerIssues,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
state: BuilderState;
|
state: BuilderState;
|
||||||
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
||||||
triggerIssues: ValidationIssue[];
|
triggerIssues: ValidationIssue[];
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border-border bg-card rounded-lg border p-4">
|
<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 className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
When…
|
{t('whenLabel')}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={state.trigger_type}
|
value={state.trigger_type}
|
||||||
@ -290,13 +294,13 @@ function TriggerPanel({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="keyword">
|
<SelectItem value="keyword">
|
||||||
A message contains a keyword
|
{t('triggerKeywordTitle')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="first_inbound_message">
|
<SelectItem value="first_inbound_message">
|
||||||
Customer's first ever inbound message
|
{t('triggerFirstInboundTitle')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="manual">
|
<SelectItem value="manual">
|
||||||
Manual only (no auto-trigger)
|
{t('triggerManualTitle')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -304,7 +308,7 @@ function TriggerPanel({
|
|||||||
{state.trigger_type === 'keyword' && (
|
{state.trigger_type === 'keyword' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
Keywords (comma-separated)
|
{t('keywordsLabel')}
|
||||||
</label>
|
</label>
|
||||||
<KeywordsInput
|
<KeywordsInput
|
||||||
keywords={
|
keywords={
|
||||||
@ -318,6 +322,7 @@ function TriggerPanel({
|
|||||||
trigger_config: { ...s.trigger_config, keywords },
|
trigger_config: { ...s.trigger_config, keywords },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -340,20 +345,22 @@ function TriggerPanel({
|
|||||||
function EntryPicker({
|
function EntryPicker({
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
state: BuilderState;
|
state: BuilderState;
|
||||||
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
setState: React.Dispatch<React.SetStateAction<BuilderState>>;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
if (state.nodes.length === 0) return null;
|
if (state.nodes.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
|
<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" />
|
<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
|
<NodeKeySelect
|
||||||
value={state.entry_node_id}
|
value={state.entry_node_id}
|
||||||
nodes={state.nodes}
|
nodes={state.nodes}
|
||||||
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
|
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
|
||||||
placeholder="Pick the first node…"
|
placeholder={t('entryNodePlaceholder')}
|
||||||
className="max-w-xs flex-1"
|
className="max-w-xs flex-1"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@ -377,6 +384,7 @@ function NodeCard({
|
|||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSetEntry,
|
onSetEntry,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
node: BuilderNode;
|
node: BuilderNode;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
@ -390,6 +398,7 @@ function NodeCard({
|
|||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onSetEntry: () => void;
|
onSetEntry: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
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);
|
||||||
@ -425,7 +434,7 @@ function NodeCard({
|
|||||||
className="truncate text-[11px] font-semibold tracking-wider uppercase"
|
className="truncate text-[11px] font-semibold tracking-wider uppercase"
|
||||||
style={{ color: c.text }}
|
style={{ color: c.text }}
|
||||||
>
|
>
|
||||||
{meta.label}
|
{t(`nodes.${node.node_type}.label`)}
|
||||||
</span>
|
</span>
|
||||||
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
|
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
|
||||||
{node.node_key}
|
{node.node_key}
|
||||||
@ -435,7 +444,7 @@ function NodeCard({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
|
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
|
||||||
>
|
>
|
||||||
Entry
|
{t('badgeEntry')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -461,12 +470,13 @@ function NodeCard({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
|
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isEntry && (
|
{!isEntry && (
|
||||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||||
Set as entry
|
{t('setAsEntry')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -477,7 +487,7 @@ function NodeCard({
|
|||||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Remove node
|
{t('removeNode')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
@ -504,11 +514,13 @@ function NodeConfigWithAdvanced({
|
|||||||
allNodes,
|
allNodes,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
node: BuilderNode;
|
node: BuilderNode;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
onUpdate: (patch: Partial<BuilderNode>) => void;
|
onUpdate: (patch: Partial<BuilderNode>) => void;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const hasReplyIds =
|
const hasReplyIds =
|
||||||
@ -532,13 +544,13 @@ function NodeConfigWithAdvanced({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
{showAdvanced ? 'Hide' : 'Show'} advanced
|
{showAdvanced ? t('hideAdvanced') : t('showAdvanced')}
|
||||||
</button>
|
</button>
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
Node key (internal identifier — keep stable for analytics)
|
{t('nodeKeyLabel')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={node.node_key}
|
value={node.node_key}
|
||||||
@ -550,9 +562,7 @@ function NodeConfigWithAdvanced({
|
|||||||
</div>
|
</div>
|
||||||
{hasReplyIds && (
|
{hasReplyIds && (
|
||||||
<p className="text-muted-foreground text-[10px]">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
Reply IDs for each option are shown inline above. They're
|
{t('replyIdsHint')}
|
||||||
returned by WhatsApp when a customer taps; you usually
|
|
||||||
don't need to touch them.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -566,7 +576,7 @@ function NodeConfigWithAdvanced({
|
|||||||
// Add-node menu
|
// 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[] = [
|
const types: NodeType[] = [
|
||||||
'start',
|
'start',
|
||||||
'send_buttons',
|
'send_buttons',
|
||||||
@ -583,10 +593,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<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"
|
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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add node
|
{t('addNode')}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||||
{groupNodeTypesByCategory(types).map((group, i) => (
|
{groupNodeTypesByCategory(types).map((group, i) => (
|
||||||
@ -598,14 +608,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
|||||||
{i > 0 && <DropdownMenuSeparator />}
|
{i > 0 && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
|
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
|
||||||
{group.label}
|
{t(`categories.${group.id}`)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{group.types.map((t) => {
|
{group.types.map((t_type) => {
|
||||||
const meta = NODE_META[t];
|
const meta = NODE_META[t_type];
|
||||||
return (
|
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.icon className={cn('h-3.5 w-3.5', meta.color)} />
|
||||||
{meta.label}
|
{t(`nodes.${t_type}.label`)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -59,6 +59,8 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -132,6 +134,7 @@ function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlowNodeCard({ data, selected }: NodeProps) {
|
function FlowNodeCard({ data, selected }: NodeProps) {
|
||||||
|
const t = useTranslations('Flows.builder');
|
||||||
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);
|
||||||
@ -191,11 +194,11 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
|||||||
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
|
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
|
||||||
style={{ color: c.text }}
|
style={{ color: c.text }}
|
||||||
>
|
>
|
||||||
{meta.label}
|
{t(`nodes.${node.node_type}.label`)}
|
||||||
</span>
|
</span>
|
||||||
{isEntry && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -270,6 +273,7 @@ export function FlowCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvasInner() {
|
function FlowCanvasInner() {
|
||||||
|
const t = useTranslations('Flows.builder');
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
@ -508,8 +512,8 @@ function FlowCanvasInner() {
|
|||||||
if (rfNodes.length === 0) {
|
if (rfNodes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
|
||||||
<p>No nodes yet.</p>
|
<p>{t('noNodesYet')}</p>
|
||||||
<CanvasAddNodeButton />
|
<CanvasAddNodeButton t={t} />
|
||||||
</div>
|
</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)]"
|
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">
|
<Panel position="top-left" className="!top-4 !left-4">
|
||||||
<CanvasAddNodeButton />
|
<CanvasAddNodeButton t={t} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
@ -578,6 +582,7 @@ function FlowCanvasInner() {
|
|||||||
onUpdateConfig={onSelectedUpdateConfig}
|
onUpdateConfig={onSelectedUpdateConfig}
|
||||||
onDelete={handleDeleteSelected}
|
onDelete={handleDeleteSelected}
|
||||||
onSetEntry={handleSetEntry}
|
onSetEntry={handleSetEntry}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -597,6 +602,7 @@ function NodeEditSheet({
|
|||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSetEntry,
|
onSetEntry,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
node: BuilderNode | null;
|
node: BuilderNode | null;
|
||||||
isEntry: boolean;
|
isEntry: boolean;
|
||||||
@ -605,6 +611,7 @@ function NodeEditSheet({
|
|||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onSetEntry: () => void;
|
onSetEntry: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
// Sheet is controlled — opens when a node is selected, closes via
|
// Sheet is controlled — opens when a node is selected, closes via
|
||||||
// Esc / overlay / close button (all delegated to onClose).
|
// Esc / overlay / close button (all delegated to onClose).
|
||||||
@ -628,15 +635,15 @@ function NodeEditSheet({
|
|||||||
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
|
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
|
<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 && (
|
{isEntry && (
|
||||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
|
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{meta.blurb}
|
{t(`nodes.${node.node_type}.blurb`)}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</div>
|
</div>
|
||||||
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
<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">
|
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
|
||||||
{!isEntry ? (
|
{!isEntry ? (
|
||||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||||
Set as entry
|
{t('setAsEntry')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
@ -668,7 +675,7 @@ function NodeEditSheet({
|
|||||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Delete node
|
{t('deleteNode')}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@ -696,7 +703,7 @@ const ADD_NODE_TYPES: NodeType[] = [
|
|||||||
'end',
|
'end',
|
||||||
];
|
];
|
||||||
|
|
||||||
function CanvasAddNodeButton() {
|
function CanvasAddNodeButton({ t }: { t: ReturnType<typeof useTranslations> }) {
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
const { addNode, updateNodePosition } = useFlowEditor();
|
const { addNode, updateNodePosition } = useFlowEditor();
|
||||||
|
|
||||||
@ -728,10 +735,10 @@ function CanvasAddNodeButton() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Add node
|
{t('addNode')}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
@ -746,28 +753,28 @@ function CanvasAddNodeButton() {
|
|||||||
{i > 0 && <DropdownMenuSeparator />}
|
{i > 0 && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
|
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
|
||||||
{group.label}
|
{t(`categories.${group.id}`)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{group.types.map((t) => {
|
{group.types.map((t_type) => {
|
||||||
const meta = NODE_META[t];
|
const meta = NODE_META[t_type];
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={t}
|
key={t_type}
|
||||||
onClick={() => handleAdd(t)}
|
onClick={() => handleAdd(t_type)}
|
||||||
className="gap-3 py-2"
|
className="gap-3 py-2"
|
||||||
>
|
>
|
||||||
<NodeIconChip
|
<NodeIconChip
|
||||||
type={t}
|
type={t_type}
|
||||||
size={28}
|
size={28}
|
||||||
iconSize={16}
|
iconSize={16}
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span className="text-popover-foreground text-[13px] font-semibold">
|
<span className="text-popover-foreground text-[13px] font-semibold">
|
||||||
{meta.label}
|
{t(`nodes.${t_type}.label`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[11.5px]">
|
<span className="text-muted-foreground text-[11.5px]">
|
||||||
{meta.blurb}
|
{t(`nodes.${t_type}.blurb`)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { ValidationPanel } from "./validation-panel";
|
|||||||
import { NODE_META, nodeColors, type NodeType } from "./shared";
|
import { NODE_META, nodeColors, type NodeType } from "./shared";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
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.
|
* Below this viewport width we force list view and hide the toggle.
|
||||||
@ -59,6 +60,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||||
|
const t = useTranslations("Flows.builder");
|
||||||
|
|
||||||
// Read the persisted choice in the useState initializer. Safe even
|
// Read the persisted choice in the useState initializer. Safe even
|
||||||
// though this is a client component because the parent page only
|
// though this is a client component because the parent page only
|
||||||
// mounts us AFTER a client-side fetch resolves — there's no SSR
|
// 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"}
|
active={effectiveView === "canvas"}
|
||||||
onClick={() => choose("canvas")}
|
onClick={() => choose("canvas")}
|
||||||
icon={<GitFork className="h-3.5 w-3.5" />}
|
icon={<GitFork className="h-3.5 w-3.5" />}
|
||||||
label="Canvas"
|
label={t("canvasView")}
|
||||||
/>
|
/>
|
||||||
<SegButton
|
<SegButton
|
||||||
active={effectiveView === "list"}
|
active={effectiveView === "list"}
|
||||||
onClick={() => choose("list")}
|
onClick={() => choose("list")}
|
||||||
icon={<List className="h-3.5 w-3.5" />}
|
icon={<List className="h-3.5 w-3.5" />}
|
||||||
label="List"
|
label={t("listView")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
|
<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
|
<span
|
||||||
key={t}
|
key={t_type}
|
||||||
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
|
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { NODE_META, type BuilderNode } from "../shared";
|
import { NODE_META, type BuilderNode } from "../shared";
|
||||||
|
|
||||||
export function TextRow({
|
export function TextRow({
|
||||||
@ -83,7 +84,7 @@ export function NextNodeRow({
|
|||||||
nodes={allNodes}
|
nodes={allNodes}
|
||||||
excludeKey={currentKey}
|
excludeKey={currentKey}
|
||||||
onChange={(v) => onChange(v ?? "")}
|
onChange={(v) => onChange(v ?? "")}
|
||||||
placeholder="Pick a next node…"
|
placeholder={useTranslations("Flows.builder.form")("pickNextNode")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -104,6 +105,7 @@ export function NodeKeySelect({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("Flows.builder.form");
|
||||||
const options = nodes.filter((n) => n.node_key !== excludeKey);
|
const options = nodes.filter((n) => n.node_key !== excludeKey);
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@ -114,7 +116,7 @@ export function NodeKeySelect({
|
|||||||
<SelectValue placeholder={placeholder ?? "—"} />
|
<SelectValue placeholder={placeholder ?? "—"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">— None —</SelectItem>
|
<SelectItem value="__none__">{t("none")}</SelectItem>
|
||||||
{options.map((n) => {
|
{options.map((n) => {
|
||||||
const Icon = NODE_META[n.node_type].icon;
|
const Icon = NODE_META[n.node_type].icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -62,6 +63,7 @@ export function NodeConfigForm({
|
|||||||
showAdvanced,
|
showAdvanced,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
}: NodeConfigFormProps) {
|
}: NodeConfigFormProps) {
|
||||||
|
const t = useTranslations("Flows.builder.form");
|
||||||
const cfg = node.config;
|
const cfg = node.config;
|
||||||
switch (node.node_type) {
|
switch (node.node_type) {
|
||||||
case "start":
|
case "start":
|
||||||
@ -71,7 +73,7 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||||
label="Advances to"
|
label={t("advancesTo")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -79,7 +81,7 @@ export function NodeConfigForm({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Text sent to the customer"
|
label={t("textToCustomer")}
|
||||||
value={(cfg as { text?: string }).text ?? ""}
|
value={(cfg as { text?: string }).text ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ text: v })}
|
onChange={(v) => onUpdateConfig({ text: v })}
|
||||||
/>
|
/>
|
||||||
@ -88,7 +90,7 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||||
label="Advances to"
|
label={t("advancesTo")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -101,6 +103,7 @@ export function NodeConfigForm({
|
|||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
showAdvanced={showAdvanced}
|
showAdvanced={showAdvanced}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -112,6 +115,7 @@ export function NodeConfigForm({
|
|||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
showAdvanced={showAdvanced}
|
showAdvanced={showAdvanced}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -122,6 +126,7 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -129,14 +134,14 @@ export function NodeConfigForm({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Prompt sent to the customer"
|
label={t("promptToCustomer")}
|
||||||
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
|
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ prompt_text: v })}
|
onChange={(v) => onUpdateConfig({ prompt_text: v })}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<label className="mb-1 block text-xs text-muted-foreground">
|
||||||
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
|
{t("varKeyLabel")}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={(cfg as { var_key?: string }).var_key ?? ""}
|
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, ""),
|
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"
|
className="bg-muted font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
<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">
|
<code className="rounded bg-muted px-1">
|
||||||
{"{{vars."}
|
{"{{vars."}
|
||||||
{(cfg as { var_key?: string }).var_key || "name"}
|
{(cfg as { var_key?: string }).var_key || "name"}
|
||||||
@ -163,7 +168,7 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||||
label="After capturing, advance to"
|
label={t("advanceAfterCapture")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -175,6 +180,7 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -185,13 +191,14 @@ export function NodeConfigForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={node.node_key}
|
currentKey={node.node_key}
|
||||||
onUpdateConfig={onUpdateConfig}
|
onUpdateConfig={onUpdateConfig}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "handoff":
|
case "handoff":
|
||||||
return (
|
return (
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Internal note (for the agent picking up)"
|
label={t("internalNote")}
|
||||||
value={(cfg as { note?: string }).note ?? ""}
|
value={(cfg as { note?: string }).note ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ note: v })}
|
onChange={(v) => onUpdateConfig({ note: v })}
|
||||||
rows={2}
|
rows={2}
|
||||||
@ -201,8 +208,7 @@ export function NodeConfigForm({
|
|||||||
case "end":
|
case "end":
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Terminal node. When the runner reaches this node the run is marked
|
{t("endNodeHelp")}
|
||||||
complete. No config needed.
|
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -224,12 +230,14 @@ function SendButtonsForm({
|
|||||||
currentKey,
|
currentKey,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
showAdvanced,
|
showAdvanced,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
cfg: SendButtonsCfg;
|
cfg: SendButtonsCfg;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
currentKey: string;
|
currentKey: string;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
showAdvanced: boolean;
|
showAdvanced: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const buttons = cfg.buttons ?? [];
|
const buttons = cfg.buttons ?? [];
|
||||||
const updateButton = (
|
const updateButton = (
|
||||||
@ -257,20 +265,20 @@ function SendButtonsForm({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Body text"
|
label={t("bodyText")}
|
||||||
value={cfg.text ?? ""}
|
value={cfg.text ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ text: v })}
|
onChange={(v) => onUpdateConfig({ text: v })}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Footer (optional, 60 chars)"
|
label={t("footerText")}
|
||||||
value={cfg.footer_text ?? ""}
|
value={cfg.footer_text ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<label className="text-xs text-muted-foreground">
|
<label className="text-xs text-muted-foreground">
|
||||||
Buttons (1–3) — each one routes to a different next node
|
{t("buttonsHelp")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@ -299,7 +307,7 @@ function SendButtonsForm({
|
|||||||
<Input
|
<Input
|
||||||
value={b.title}
|
value={b.title}
|
||||||
onChange={(e) => updateButton(i, { title: e.target.value })}
|
onChange={(e) => updateButton(i, { title: e.target.value })}
|
||||||
placeholder="Visible title (≤20 chars)"
|
placeholder={t("optionTitlePlaceholder")}
|
||||||
className="bg-muted"
|
className="bg-muted"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
/>
|
/>
|
||||||
@ -308,7 +316,7 @@ function SendButtonsForm({
|
|||||||
nodes={allNodes}
|
nodes={allNodes}
|
||||||
excludeKey={currentKey}
|
excludeKey={currentKey}
|
||||||
onChange={(v) => updateButton(i, { next_node_key: v ?? "" })}
|
onChange={(v) => updateButton(i, { next_node_key: v ?? "" })}
|
||||||
placeholder="Next node…"
|
placeholder={t("nextNodePlaceholder")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -329,7 +337,7 @@ function SendButtonsForm({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add button
|
{t("addButton")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -362,12 +370,14 @@ function SendListForm({
|
|||||||
currentKey,
|
currentKey,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
showAdvanced,
|
showAdvanced,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
cfg: SendListCfg;
|
cfg: SendListCfg;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
currentKey: string;
|
currentKey: string;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
showAdvanced: boolean;
|
showAdvanced: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const sections = cfg.sections ?? [];
|
const sections = cfg.sections ?? [];
|
||||||
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
|
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">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Tap-to-expand button label (≤20 chars)"
|
label={t("buttonLabel")}
|
||||||
value={cfg.button_label ?? ""}
|
value={cfg.button_label ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ button_label: v })}
|
onChange={(v) => onUpdateConfig({ button_label: v })}
|
||||||
/>
|
/>
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Footer (optional, 60 chars)"
|
label={t("footerText")}
|
||||||
value={cfg.footer_text ?? ""}
|
value={cfg.footer_text ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
onChange={(v) => onUpdateConfig({ footer_text: v })}
|
||||||
/>
|
/>
|
||||||
@ -466,7 +476,7 @@ function SendListForm({
|
|||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<label className="mb-2 block text-xs text-muted-foreground">
|
<label className="mb-2 block text-xs text-muted-foreground">
|
||||||
Rows (1–10 total across all sections)
|
{t("rowsHelp")}
|
||||||
</label>
|
</label>
|
||||||
{sections.map((section, sIdx) => (
|
{sections.map((section, sIdx) => (
|
||||||
<div
|
<div
|
||||||
@ -479,7 +489,7 @@ function SendListForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSection(sIdx, { title: e.target.value })
|
updateSection(sIdx, { title: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={`Section ${sIdx + 1} title (optional)`}
|
placeholder={t("sectionTitlePlaceholder", { count: sIdx + 1 })}
|
||||||
className="bg-muted text-xs"
|
className="bg-muted text-xs"
|
||||||
/>
|
/>
|
||||||
{sections.length > 1 && (
|
{sections.length > 1 && (
|
||||||
@ -524,7 +534,7 @@ function SendListForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateRow(sIdx, rIdx, { title: e.target.value })
|
updateRow(sIdx, rIdx, { title: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Row title (≤24)"
|
placeholder={t("rowTitlePlaceholder")}
|
||||||
className="bg-muted"
|
className="bg-muted"
|
||||||
maxLength={24}
|
maxLength={24}
|
||||||
/>
|
/>
|
||||||
@ -535,7 +545,7 @@ function SendListForm({
|
|||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
updateRow(sIdx, rIdx, { next_node_key: v ?? "" })
|
updateRow(sIdx, rIdx, { next_node_key: v ?? "" })
|
||||||
}
|
}
|
||||||
placeholder="Next node…"
|
placeholder={t("nextNodePlaceholder")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -555,7 +565,7 @@ function SendListForm({
|
|||||||
className="mt-1"
|
className="mt-1"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add row
|
{t("addRow")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -566,7 +576,7 @@ function SendListForm({
|
|||||||
{sections.length < 10 && (
|
{sections.length < 10 && (
|
||||||
<Button variant="outline" size="sm" onClick={addSection}>
|
<Button variant="outline" size="sm" onClick={addSection}>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add section
|
{t("addSection")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -598,11 +608,13 @@ function ConditionForm({
|
|||||||
allNodes,
|
allNodes,
|
||||||
currentKey,
|
currentKey,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
cfg: ConditionCfg;
|
cfg: ConditionCfg;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
currentKey: string;
|
currentKey: string;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const tags = useUserTags();
|
const tags = useUserTags();
|
||||||
|
|
||||||
@ -614,7 +626,7 @@ function ConditionForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={subject}
|
value={subject}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -625,19 +637,19 @@ function ConditionForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="var">Captured variable</SelectItem>
|
<SelectItem value="var">{t("capturedVariable")}</SelectItem>
|
||||||
<SelectItem value="tag">Contact has tag</SelectItem>
|
<SelectItem value="tag">{t("contactHasTag")}</SelectItem>
|
||||||
<SelectItem value="contact_field">Contact field</SelectItem>
|
<SelectItem value="contact_field">{t("contactField")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<label className="mb-1 block text-xs text-muted-foreground">
|
||||||
{subject === "var"
|
{subject === "var"
|
||||||
? "var name"
|
? t("varName")
|
||||||
: subject === "tag"
|
: subject === "tag"
|
||||||
? "Tag"
|
? t("tagLabel")
|
||||||
: "Field"}
|
: t("fieldLabel")}
|
||||||
</label>
|
</label>
|
||||||
{subject === "tag" && tags.length > 0 ? (
|
{subject === "tag" && tags.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
@ -661,7 +673,7 @@ function ConditionForm({
|
|||||||
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
|
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-muted">
|
<SelectTrigger className="bg-muted">
|
||||||
<SelectValue placeholder="Pick a field…" />
|
<SelectValue placeholder={t("pickField")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="name">name</SelectItem>
|
<SelectItem value="name">name</SelectItem>
|
||||||
@ -676,7 +688,7 @@ function ConditionForm({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onUpdateConfig({ subject_key: e.target.value })
|
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"
|
className="bg-muted font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -690,7 +702,7 @@ function ConditionForm({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={operator}
|
value={operator}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -701,16 +713,16 @@ function ConditionForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="present">is present</SelectItem>
|
<SelectItem value="present">{t("isPresent")}</SelectItem>
|
||||||
<SelectItem value="absent">is absent</SelectItem>
|
<SelectItem value="absent">{t("isAbsent")}</SelectItem>
|
||||||
<SelectItem value="equals">equals</SelectItem>
|
<SelectItem value="equals">{t("equals")}</SelectItem>
|
||||||
<SelectItem value="contains">contains</SelectItem>
|
<SelectItem value="contains">{t("contains")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div>
|
<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
|
<Input
|
||||||
value={cfg.value ?? ""}
|
value={cfg.value ?? ""}
|
||||||
onChange={(e) => onUpdateConfig({ value: e.target.value })}
|
onChange={(e) => onUpdateConfig({ value: e.target.value })}
|
||||||
@ -726,14 +738,14 @@ function ConditionForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={currentKey}
|
currentKey={currentKey}
|
||||||
onChange={(v) => onUpdateConfig({ true_next: v })}
|
onChange={(v) => onUpdateConfig({ true_next: v })}
|
||||||
label="If true → advance to"
|
label={t("ifTrueAdvance")}
|
||||||
/>
|
/>
|
||||||
<NextNodeRow
|
<NextNodeRow
|
||||||
value={cfg.false_next ?? ""}
|
value={cfg.false_next ?? ""}
|
||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={currentKey}
|
currentKey={currentKey}
|
||||||
onChange={(v) => onUpdateConfig({ false_next: v })}
|
onChange={(v) => onUpdateConfig({ false_next: v })}
|
||||||
label="If false → advance to"
|
label={t("ifFalseAdvance")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -755,11 +767,13 @@ function SetTagForm({
|
|||||||
allNodes,
|
allNodes,
|
||||||
currentKey,
|
currentKey,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
cfg: SetTagCfg;
|
cfg: SetTagCfg;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
currentKey: string;
|
currentKey: string;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const tags = useUserTags();
|
const tags = useUserTags();
|
||||||
|
|
||||||
@ -767,7 +781,7 @@ function SetTagForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={cfg.mode ?? "add"}
|
value={cfg.mode ?? "add"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -778,13 +792,13 @@ function SetTagForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="add">Add tag</SelectItem>
|
<SelectItem value="add">{t("addTag")}</SelectItem>
|
||||||
<SelectItem value="remove">Remove tag</SelectItem>
|
<SelectItem value="remove">{t("removeTag")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<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 ? (
|
{tags.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
value={cfg.tag_id ?? ""}
|
value={cfg.tag_id ?? ""}
|
||||||
@ -805,7 +819,7 @@ function SetTagForm({
|
|||||||
<Input
|
<Input
|
||||||
value={cfg.tag_id ?? ""}
|
value={cfg.tag_id ?? ""}
|
||||||
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
|
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
|
||||||
placeholder="Tag UUID"
|
placeholder={t("tagUuidPlaceholder")}
|
||||||
className="bg-muted font-mono text-xs"
|
className="bg-muted font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -816,7 +830,7 @@ function SetTagForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={currentKey}
|
currentKey={currentKey}
|
||||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||||
label="Then advance to"
|
label={t("thenAdvanceTo")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -878,11 +892,13 @@ function SendMediaForm({
|
|||||||
allNodes,
|
allNodes,
|
||||||
currentKey,
|
currentKey,
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
cfg: SendMediaCfg;
|
cfg: SendMediaCfg;
|
||||||
allNodes: BuilderNode[];
|
allNodes: BuilderNode[];
|
||||||
currentKey: string;
|
currentKey: string;
|
||||||
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
onUpdateConfig: (patch: Record<string, unknown>) => void;
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@ -930,7 +946,7 @@ function SendMediaForm({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<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
|
<Select
|
||||||
value={mediaType}
|
value={mediaType}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@ -948,17 +964,17 @@ function SendMediaForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="image">Image (PNG, JPEG, WebP)</SelectItem>
|
<SelectItem value="image">{t("imageLabel")}</SelectItem>
|
||||||
<SelectItem value="video">Video (MP4, 3GP)</SelectItem>
|
<SelectItem value="video">{t("videoLabel")}</SelectItem>
|
||||||
<SelectItem value="document">
|
<SelectItem value="document">
|
||||||
Document (PDF, Word, Excel, PowerPoint, TXT)
|
{t("documentLabel")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 ? (
|
{cfg.media_url ? (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
|
<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" />
|
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
||||||
@ -975,7 +991,7 @@ function SendMediaForm({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
aria-label="Remove file"
|
aria-label={t("removeFile")}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
@ -991,12 +1007,12 @@ function SendMediaForm({
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Uploading…
|
{t("uploading")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
Click to upload (max 16 MB)
|
{t("clickToUpload")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -1016,7 +1032,7 @@ function SendMediaForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextRow
|
<TextRow
|
||||||
label="Caption (optional, shown under the media)"
|
label={t("captionLabel")}
|
||||||
value={cfg.caption ?? ""}
|
value={cfg.caption ?? ""}
|
||||||
onChange={(v) => onUpdateConfig({ caption: v })}
|
onChange={(v) => onUpdateConfig({ caption: v })}
|
||||||
rows={2}
|
rows={2}
|
||||||
@ -1025,12 +1041,12 @@ function SendMediaForm({
|
|||||||
{isDocument && (
|
{isDocument && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<label className="mb-1 block text-xs text-muted-foreground">
|
||||||
Filename shown to the customer (documents only)
|
{t("filenameLabel")}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={cfg.filename ?? ""}
|
value={cfg.filename ?? ""}
|
||||||
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
|
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
|
||||||
placeholder="invoice.pdf"
|
placeholder={t("filenamePlaceholder")}
|
||||||
className="bg-muted text-xs"
|
className="bg-muted text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1041,7 +1057,7 @@ function SendMediaForm({
|
|||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
currentKey={currentKey}
|
currentKey={currentKey}
|
||||||
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
onChange={(v) => onUpdateConfig({ next_node_key: v })}
|
||||||
label="After sending, advance to"
|
label={t("advanceAfterSending")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user