wacrm/src/components/flows/forms/node-config-form.tsx

1065 lines
32 KiB
TypeScript

"use client";
/**
* Per-node configuration form, dispatched by node_type.
*
* One component, ten branches. Each branch renders the inputs that
* map onto the node's `config` JSONB shape (text + buttons for
* send_buttons, prompt + var_key for collect_input, etc.) and forwards
* edits up via `onUpdateConfig`.
*
* Why this lives in src/components/flows/forms/ instead of next to
* the list editor: PR 2 (canvas editing) needs to mount the same
* form in a side panel when a user clicks a node on the canvas.
* Keeping the per-node forms here means there's exactly one place
* where each form's behaviour and validation lives — drift between
* "what the list editor shows" and "what the canvas side panel
* shows" becomes impossible.
*
* `showAdvanced` is the disclosure that surfaces internal
* identifiers (node_key, button reply_id, list row reply_id) — owned
* by the host (NodeCard / SideSheet) so the toggle is rendered
* outside this form alongside whatever delete/cancel buttons that
* host wants. The form just reads the boolean and conditionally
* renders the advanced rows.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Loader2,
Paperclip,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { uploadAccountMedia, MEDIA_MAX_BYTES } from "@/lib/storage/upload-media";
import { slugify, type BuilderNode } from "../shared";
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
interface NodeConfigFormProps {
node: BuilderNode;
allNodes: BuilderNode[];
showAdvanced: boolean;
onUpdateConfig: (patch: Record<string, unknown>) => void;
}
export function NodeConfigForm({
node,
allNodes,
showAdvanced,
onUpdateConfig,
}: NodeConfigFormProps) {
const t = useTranslations("Flows.builder.form");
const cfg = node.config;
switch (node.node_type) {
case "start":
return (
<NextNodeRow
value={(cfg as { next_node_key?: string }).next_node_key ?? ""}
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label={t("advancesTo")}
/>
);
case "send_message":
return (
<>
<TextRow
label={t("textToCustomer")}
value={(cfg as { text?: string }).text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })}
/>
<NextNodeRow
value={(cfg as { next_node_key?: string }).next_node_key ?? ""}
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label={t("advancesTo")}
/>
</>
);
case "send_buttons":
return (
<SendButtonsForm
cfg={cfg as SendButtonsCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced}
t={t}
/>
);
case "send_list":
return (
<SendListForm
cfg={cfg as SendListCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced}
t={t}
/>
);
case "send_media":
return (
<SendMediaForm
cfg={cfg as SendMediaCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
case "collect_input":
return (
<>
<TextRow
label={t("promptToCustomer")}
value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
onChange={(v) => onUpdateConfig({ prompt_text: v })}
rows={2}
/>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
{t("varKeyLabel")}
</label>
<Input
value={(cfg as { var_key?: string }).var_key ?? ""}
onChange={(e) =>
onUpdateConfig({
var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""),
})
}
placeholder={t("varKeyPlaceholder")}
className="bg-muted font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
{t("varKeyHelp")}{" "}
<code className="rounded bg-muted px-1">
{"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"}
{"}}"}
</code>
.
</p>
</div>
<NextNodeRow
value={(cfg as { next_node_key?: string }).next_node_key ?? ""}
allNodes={allNodes}
currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label={t("advanceAfterCapture")}
/>
</>
);
case "condition":
return (
<ConditionForm
cfg={cfg as ConditionCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
case "set_tag":
return (
<SetTagForm
cfg={cfg as SetTagCfg}
allNodes={allNodes}
currentKey={node.node_key}
onUpdateConfig={onUpdateConfig}
t={t}
/>
);
case "handoff":
return (
<TextRow
label={t("internalNote")}
value={(cfg as { note?: string }).note ?? ""}
onChange={(v) => onUpdateConfig({ note: v })}
rows={2}
/>
);
case "end":
return (
<p className="text-xs text-muted-foreground">
{t("endNodeHelp")}
</p>
);
}
}
// ============================================================
// send_buttons
// ============================================================
interface SendButtonsCfg {
text?: string;
footer_text?: string;
buttons?: Array<{ reply_id: string; title: string; next_node_key: string }>;
}
function SendButtonsForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
showAdvanced,
t,
}: {
cfg: SendButtonsCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) {
const buttons = cfg.buttons ?? [];
const updateButton = (
idx: number,
patch: Partial<NonNullable<SendButtonsCfg["buttons"]>[number]>,
) => {
onUpdateConfig({
buttons: buttons.map((b, i) => (i === idx ? { ...b, ...patch } : b)),
});
};
const addButton = () =>
onUpdateConfig({
buttons: [
...buttons,
{
reply_id: `btn_${buttons.length + 1}`,
title: "Option",
next_node_key: "",
},
],
});
const removeButton = (idx: number) =>
onUpdateConfig({ buttons: buttons.filter((_, i) => i !== idx) });
return (
<>
<TextRow
label={t("bodyText")}
value={cfg.text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })}
rows={3}
/>
<TextRow
label={t("footerText")}
value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })}
/>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs text-muted-foreground">
{t("buttonsHelp")}
</label>
</div>
<div className="flex flex-col gap-3">
{buttons.map((b, i) => (
<div
key={i}
className={cn(
"grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/40 p-3",
showAdvanced
? "md:grid-cols-[1fr_2fr_2fr_auto]"
: "md:grid-cols-[2fr_2fr_auto]",
)}
>
{showAdvanced && (
<Input
value={b.reply_id}
onChange={(e) =>
updateButton(i, {
reply_id: slugify(e.target.value, `btn_${i + 1}`),
})
}
placeholder="reply_id"
className="bg-muted font-mono text-xs"
/>
)}
<Input
value={b.title}
onChange={(e) => updateButton(i, { title: e.target.value })}
placeholder={t("optionTitlePlaceholder")}
className="bg-muted"
maxLength={20}
/>
<NodeKeySelect
value={b.next_node_key || null}
nodes={allNodes}
excludeKey={currentKey}
onChange={(v) => updateButton(i, { next_node_key: v ?? "" })}
placeholder={t("nextNodePlaceholder")}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeButton(i)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{buttons.length < 3 && (
<Button
variant="ghost"
size="sm"
onClick={addButton}
className="mt-2"
>
<Plus className="h-3.5 w-3.5" />
{t("addButton")}
</Button>
)}
</div>
</>
);
}
// ============================================================
// send_list
// ============================================================
interface SendListCfg {
text?: string;
button_label?: string;
footer_text?: string;
sections?: Array<{
title?: string;
rows: Array<{
reply_id: string;
title: string;
description?: string;
next_node_key: string;
}>;
}>;
}
function SendListForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
showAdvanced,
t,
}: {
cfg: SendListCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) {
const sections = cfg.sections ?? [];
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
const updateSection = (
sIdx: number,
patch: Partial<NonNullable<SendListCfg["sections"]>[number]>,
) => {
onUpdateConfig({
sections: sections.map((s, i) =>
i === sIdx ? { ...s, ...patch } : s,
),
});
};
const addSection = () =>
onUpdateConfig({
sections: [
...sections,
{
title: "",
rows: [
{
reply_id: `row_${totalRows + 1}`,
title: `Option ${totalRows + 1}`,
next_node_key: "",
},
],
},
],
});
const removeSection = (sIdx: number) =>
onUpdateConfig({ sections: sections.filter((_, i) => i !== sIdx) });
const updateRow = (
sIdx: number,
rIdx: number,
patch: Partial<
NonNullable<SendListCfg["sections"]>[number]["rows"][number]
>,
) => {
onUpdateConfig({
sections: sections.map((s, i) =>
i === sIdx
? {
...s,
rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)),
}
: s,
),
});
};
const addRow = (sIdx: number) =>
onUpdateConfig({
sections: sections.map((s, i) =>
i === sIdx
? {
...s,
rows: [
...s.rows,
{
reply_id: `row_${totalRows + 1}`,
title: `Option ${totalRows + 1}`,
next_node_key: "",
},
],
}
: s,
),
});
const removeRow = (sIdx: number, rIdx: number) =>
onUpdateConfig({
sections: sections.map((s, i) =>
i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s,
),
});
return (
<>
<TextRow
label="Body text"
value={cfg.text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })}
rows={3}
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextRow
label={t("buttonLabel")}
value={cfg.button_label ?? ""}
onChange={(v) => onUpdateConfig({ button_label: v })}
/>
<TextRow
label={t("footerText")}
value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })}
/>
</div>
<div className="mt-2">
<label className="mb-2 block text-xs text-muted-foreground">
{t("rowsHelp")}
</label>
{sections.map((section, sIdx) => (
<div
key={sIdx}
className="mb-3 rounded-md border border-border bg-muted/40 p-3"
>
<div className="mb-2 flex items-center gap-2">
<Input
value={section.title ?? ""}
onChange={(e) =>
updateSection(sIdx, { title: e.target.value })
}
placeholder={t("sectionTitlePlaceholder", { count: sIdx + 1 })}
className="bg-muted text-xs"
/>
{sections.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeSection(sIdx)}
className="shrink-0 text-red-400 hover:bg-red-500/10 hover:text-red-300"
aria-label="Remove section"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{section.rows.map((row, rIdx) => (
<div
key={rIdx}
className={cn(
"mb-2 grid grid-cols-1 gap-2",
showAdvanced
? "md:grid-cols-[1fr_2fr_2fr_auto]"
: "md:grid-cols-[2fr_2fr_auto]",
)}
>
{showAdvanced && (
<Input
value={row.reply_id}
onChange={(e) =>
updateRow(sIdx, rIdx, {
reply_id: slugify(
e.target.value,
`row_${rIdx + 1}`,
),
})
}
placeholder="reply_id"
className="bg-muted font-mono text-xs"
/>
)}
<Input
value={row.title}
onChange={(e) =>
updateRow(sIdx, rIdx, { title: e.target.value })
}
placeholder={t("rowTitlePlaceholder")}
className="bg-muted"
maxLength={24}
/>
<NodeKeySelect
value={row.next_node_key || null}
nodes={allNodes}
excludeKey={currentKey}
onChange={(v) =>
updateRow(sIdx, rIdx, { next_node_key: v ?? "" })
}
placeholder={t("nextNodePlaceholder")}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeRow(sIdx, rIdx)}
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{totalRows < 10 && (
<Button
variant="ghost"
size="sm"
onClick={() => addRow(sIdx)}
className="mt-1"
>
<Plus className="h-3.5 w-3.5" />
{t("addRow")}
</Button>
)}
</div>
))}
{/* WhatsApp's interactive-list spec caps sections at 10. Group rows
by category (Billing / Support / Sales etc.) to give customers a
scannable menu. */}
{sections.length < 10 && (
<Button variant="outline" size="sm" onClick={addSection}>
<Plus className="h-3.5 w-3.5" />
{t("addSection")}
</Button>
)}
</div>
</>
);
}
// ============================================================
// condition
// ============================================================
interface ConditionCfg {
subject?: "var" | "tag" | "contact_field";
subject_key?: string;
operator?: "equals" | "contains" | "present" | "absent";
value?: string;
true_next?: string;
false_next?: string;
}
interface UserTag {
id: string;
name: string;
color?: string;
}
function ConditionForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: ConditionCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const tags = useUserTags();
const subject = cfg.subject ?? "var";
const operator = cfg.operator ?? "equals";
const showValue = operator === "equals" || operator === "contains";
return (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("ifLabel")}</label>
<Select
value={subject}
onValueChange={(v) =>
onUpdateConfig({ subject: v as ConditionCfg["subject"] })
}
>
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="var">{t("capturedVariable")}</SelectItem>
<SelectItem value="tag">{t("contactHasTag")}</SelectItem>
<SelectItem value="contact_field">{t("contactField")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-xs text-muted-foreground">
{subject === "var"
? t("varName")
: subject === "tag"
? t("tagLabel")
: t("fieldLabel")}
</label>
{subject === "tag" && tags.length > 0 ? (
<Select
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : subject === "contact_field" ? (
<Select
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-muted">
<SelectValue placeholder={t("pickField")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">name</SelectItem>
<SelectItem value="email">email</SelectItem>
<SelectItem value="phone">phone</SelectItem>
<SelectItem value="company">company</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={cfg.subject_key ?? ""}
onChange={(e) =>
onUpdateConfig({ subject_key: e.target.value })
}
placeholder={subject === "var" ? t("varKeyPlaceholder") : t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs"
/>
)}
</div>
</div>
<div
className={cn(
"grid grid-cols-1 gap-3",
showValue ? "md:grid-cols-2" : "",
)}
>
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("operatorLabel")}</label>
<Select
value={operator}
onValueChange={(v) =>
onUpdateConfig({ operator: v as ConditionCfg["operator"] })
}
>
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="present">{t("isPresent")}</SelectItem>
<SelectItem value="absent">{t("isAbsent")}</SelectItem>
<SelectItem value="equals">{t("equals")}</SelectItem>
<SelectItem value="contains">{t("contains")}</SelectItem>
</SelectContent>
</Select>
</div>
{showValue && (
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("valueLabel")}</label>
<Input
value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })}
className="bg-muted"
/>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<NextNodeRow
value={cfg.true_next ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ true_next: v })}
label={t("ifTrueAdvance")}
/>
<NextNodeRow
value={cfg.false_next ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ false_next: v })}
label={t("ifFalseAdvance")}
/>
</div>
</>
);
}
// ============================================================
// set_tag
// ============================================================
interface SetTagCfg {
mode?: "add" | "remove";
tag_id?: string;
next_node_key?: string;
}
function SetTagForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: SetTagCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const tags = useUserTags();
return (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("actionLabel")}</label>
<Select
value={cfg.mode ?? "add"}
onValueChange={(v) =>
onUpdateConfig({ mode: v as SetTagCfg["mode"] })
}
>
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add">{t("addTag")}</SelectItem>
<SelectItem value="remove">{t("removeTag")}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("tagLabel")}</label>
{tags.length > 0 ? (
<Select
value={cfg.tag_id ?? ""}
onValueChange={(v) => onUpdateConfig({ tag_id: v })}
>
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder={t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs"
/>
)}
</div>
</div>
<NextNodeRow
value={cfg.next_node_key ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label={t("thenAdvanceTo")}
/>
</>
);
}
/**
* Shared loader for both `condition` (subject=tag) and `set_tag`.
* Falls back to raw UUID input if the endpoint is absent on older
* deployments — the form remains authorable in that case.
*/
function useUserTags(): UserTag[] {
const [tags, setTags] = useState<UserTag[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/tags").catch(() => null);
if (!res || !res.ok) return;
const json = (await res.json()) as { tags?: UserTag[] };
if (!cancelled) setTags(json.tags ?? []);
} catch {
// Tags endpoint absent — caller falls back to raw input.
}
})();
return () => {
cancelled = true;
};
}, []);
return tags;
}
// ============================================================
// send_media
// ============================================================
interface SendMediaCfg {
media_type?: "image" | "video" | "document";
media_url?: string;
caption?: string;
filename?: string;
next_node_key?: string;
}
// Mirrors the bucket's allowed_mime_types from migration 016. Kept in
// sync with the storage policy so the picker rejects unsupported files
// before they hit the network rather than failing with a confusing
// Supabase RLS / mime-type error.
const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
const FLOW_MEDIA_BUCKET = "flow-media";
function SendMediaForm({
cfg,
allNodes,
currentKey,
onUpdateConfig,
t,
}: {
cfg: SendMediaCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const mediaType = cfg.media_type ?? "image";
const isDocument = mediaType === "document";
const displayName =
cfg.filename ||
(cfg.media_url ? cfg.media_url.split("/").pop() ?? "" : "");
const handleFile = useCallback(
async (file: File) => {
if (file.size > MEDIA_MAX_BYTES) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
);
return;
}
setUploading(true);
try {
// Account-scoped upload (path `account-<id>/...`) — see
// uploadAccountMedia + migration 020's flow-media RLS policy.
const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
// Patch all fields in one call so the form doesn't re-render
// with a half-uploaded state.
onUpdateConfig({
media_url: publicUrl,
filename: file.name,
});
toast.success("File uploaded.");
} catch (err) {
const msg = err instanceof Error ? err.message : "Upload failed.";
toast.error(msg);
} finally {
setUploading(false);
}
},
[onUpdateConfig],
);
const handleClear = () => {
onUpdateConfig({ media_url: "", filename: "" });
};
return (
<>
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("mediaTypeLabel")}</label>
<Select
value={mediaType}
onValueChange={(v) => {
// Changing type clears the existing file — the bucket
// accepts different MIME sets per type and a previously
// uploaded PDF can't be sent as an image.
onUpdateConfig({
media_type: v as NonNullable<SendMediaCfg["media_type"]>,
media_url: "",
filename: "",
});
}}
>
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">{t("imageLabel")}</SelectItem>
<SelectItem value="video">{t("videoLabel")}</SelectItem>
<SelectItem value="document">
{t("documentLabel")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">{t("fileLabel")}</label>
{cfg.media_url ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
<a
href={cfg.media_url}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1 truncate text-foreground hover:text-cyan-300"
title={displayName || cfg.media_url}
>
{displayName || cfg.media_url}
</a>
<button
type="button"
onClick={handleClear}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t("removeFile")}
disabled={uploading}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-border bg-card px-3 py-4 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
{uploading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("uploading")}
</>
) : (
<>
<Upload className="h-3.5 w-3.5" />
{t("clickToUpload")}
</>
)}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept={MEDIA_ACCEPT[mediaType]}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
// Reset so picking the same file twice still fires onChange.
e.target.value = "";
}}
/>
</div>
<TextRow
label={t("captionLabel")}
value={cfg.caption ?? ""}
onChange={(v) => onUpdateConfig({ caption: v })}
rows={2}
/>
{isDocument && (
<div>
<label className="mb-1 block text-xs text-muted-foreground">
{t("filenameLabel")}
</label>
<Input
value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder={t("filenamePlaceholder")}
className="bg-muted text-xs"
/>
</div>
)}
<NextNodeRow
value={cfg.next_node_key ?? ""}
allNodes={allNodes}
currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })}
label={t("advanceAfterSending")}
/>
</>
);
}