+
diff --git a/src/components/ChatbotFlowView/hooks/index.tsx b/src/components/ChatbotFlowView/hooks/index.tsx
new file mode 100644
index 0000000..67b60ed
--- /dev/null
+++ b/src/components/ChatbotFlowView/hooks/index.tsx
@@ -0,0 +1,282 @@
+import { useState, useMemo, ChangeEvent, MouseEvent, useEffect } from "react";
+import { AppError } from "@/common/error";
+import { ChatNode, ChatNodeOutput, ProviderType } from "@/common/services/messaging/models";
+import { AppToast } from "@/common/ui/toast";
+import { MessageProvidersApi } from "@/domains/message-providers/client-api";
+import { useLoading } from "../../AppLayout/Loading/hooks";
+import { useDrodownMenu } from "../../Form/DropdownMenu/hooks";
+import { ModalUtils } from "../../Modal/Container/state";
+import { PromptModal } from "../../Modal/Prompt";
+import { ChatbotFlowView } from "..";
+
+export function useChatbotFlow() {
+
+ const { setLoading } = useLoading();
+ const [ ready, setReady ] = useState(false);
+
+ const [ providerId, setProviderId ] = useState(0);
+ const [ rootNode, setRootNode ] = useState
(null);
+ const [ nodesPath, setNodesPath ] = useState([]);
+ const { visible, handleToggleDropdown, handleDropdownItemClick } = useDrodownMenu(ChatbotFlowView.name);
+
+ const nodesIndex = useMemo(() => rootNode ? makeChatNodesIndex(rootNode) : {}, [ rootNode ]);
+
+ const curretNodeId = nodesPath.at(-1);
+ const currentNode = curretNodeId ? nodesIndex[curretNodeId] : null;
+
+
+ const handleNext = (node: ChatNode) => () => {
+
+ setNodesPath(nodes => [...nodes, node.id ]);
+ };
+
+ const handlePrevious = (node: ChatNode) => () => {
+
+ setNodesPath(nodes => {
+
+ const nodeIndex = nodes.findIndex(nodeId => nodeId === node.id);
+ const nodesUpdateds = nodes.slice(0, nodeIndex + 1);
+
+ return nodesUpdateds;
+ });
+ };
+
+ const handleAddResponse = (type: 'text' | 'media-link') => (event: MouseEvent) => {
+
+ handleDropdownItemClick(event);
+
+ if (!currentNode || !rootNode) return;
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.output = [ ...node.output, { type, content: '' } ]
+ }));
+ };
+
+ const handleShowAddChild = () => {
+
+ if (!currentNode) return;
+
+ ModalUtils.show({
+ id: 'chatbot-flow-new-child',
+ modal:
+ });
+ };
+
+ const handleAddChild = (confirmed: boolean, childLabel: string | null) => {
+
+ ModalUtils.hide('chatbot-flow-new-child');
+
+ if (!currentNode || !rootNode || !confirmed || !childLabel) return;
+
+ const newNode: ChatNode = {
+ id: new Date().getTime().toString(),
+ label: childLabel,
+ pattern: '',
+ output: [{ type: 'text', content: '' }],
+ childs: []
+ };
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.childs = [ ...node.childs, newNode ]
+ }));
+
+ setNodesPath(nodes => [ ...nodes, newNode.id ]);
+ };
+
+ const handleRemoveChild = (nodeSelected: ChatNode) => () => {
+
+ if (!currentNode || !rootNode) return;
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.childs = node.childs.filter(x => x.id !== nodeSelected.id)
+ }));
+ };
+
+ const handlePatternChange = (event: ChangeEvent) => {
+
+ if (!currentNode || !rootNode) return;
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.pattern = event.target.value ?? '';
+ }));
+ }
+
+ const handleOutputContentChange = ({ type, content }: ChatNodeOutput) => (event: ChangeEvent) => {
+
+ if (!currentNode || !rootNode) return;
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.output = updateArrayItem(node.output,
+ x => x.content === content,
+ () => ({ type, content: event.currentTarget.innerText ?? '' }))
+ }));
+ };
+
+ const handleRemoveOutput = ({ content }: ChatNodeOutput) => (event: MouseEvent) => {
+
+ if (!currentNode || !rootNode) return;
+
+ setRootNode(updateNode(rootNode, node => {
+
+ if (node.id !== currentNode.id) return;
+
+ node.output = node.output.filter(x => x.content !== content)
+ }));
+ };
+
+ const handleSave = async () => {
+
+ if (!rootNode) return;
+
+ setLoading(true);
+
+ try {
+ const api = new MessageProvidersApi();
+
+ await api.updateChatbotFlow({
+ providerId: providerId,
+ chatbotFlow: rootNode
+ });
+
+ AppToast.success('Diálogo salvo');
+ }
+ catch (error) {
+
+ AppToast.error(AppError.parse(error).message);
+ }
+ finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+
+ (async () => {
+
+ try {
+ const api = new MessageProvidersApi();
+ const providersStatus = await api.getProvidersStatus();
+
+ const whatsappProvider = providersStatus?.find(x => x.type === ProviderType.Whatsapp);
+ if (!whatsappProvider) {
+ AppToast.error('Configure a integração com Whatsapp para ter acesso ao chatbot');
+ return;
+ }
+
+ const rootNode = await api.getChatbotFlow({ id: whatsappProvider.id }) ?? { id: 'inicial', label: 'Mensagem inicial', childs: [], output: [{ type: 'text', content: 'Olá! Pronto pra começar' }], pattern: '.*' };
+
+ setRootNode(rootNode);
+ setNodesPath([rootNode.id]);
+ setProviderId(whatsappProvider.id);
+ setReady(true);
+ }
+ catch (error) {
+
+ AppToast.error(AppError.parse(error).message);
+ }
+
+ })();
+
+ }, []);
+
+ return {
+ ready,
+ currentNode,
+ nodesIndex,
+ nodesPath,
+ visible,
+ handleToggleDropdown,
+ handleNext,
+ handlePrevious,
+ handleAddResponse,
+ handleShowAddChild,
+ handleRemoveChild,
+ handlePatternChange,
+ handleOutputContentChange,
+ handleRemoveOutput,
+ handleSave,
+ };
+}
+
+
+const updateArrayItem = (array: T[], selector: (item: T) => boolean, newItem: (item: T) => T) => {
+
+ const newArray = [];
+ for(let i = 0; i < array.length; i++) {
+
+ if (selector(array[i]))
+ newArray[i] = newItem(array[i]);
+ else
+ newArray[i] = array[i];
+ }
+
+ return newArray;
+};
+
+function makeChatNodesIndex(root: ChatNode) {
+
+ const index: Record = {};
+
+ const visit = (node: ChatNode) => {
+
+ if (index[node.id])
+ throw new Error('Duplicate node id: ' + node.id);
+
+ index[node.id] = node;
+ for(const child of node.childs) {
+ visit(child);
+ }
+ };
+
+ visit(root);
+
+ return index;
+}
+
+function getNodeFromPath(nodesPath: string[], rootNode: ChatNode) {
+
+ let parentNode = null;
+ let node = rootNode;
+ for(const nodeId of nodesPath) {
+
+ if (nodeId === node.id)
+ continue;
+
+ const child = node.childs.find(x => x.id == nodeId);
+ if (!child)
+ throw new Error('Node not found in path: ' + nodesPath.join("/"));
+
+ parentNode = node;
+ node = child;
+ }
+
+ return { parentNode, node };
+}
+
+function updateNode(rootNode: ChatNode, update: (node: ChatNode) => void) {
+
+ const newRootNode = {
+ ...rootNode
+ };
+
+ update(newRootNode);
+
+ newRootNode.childs = newRootNode.childs.map(child => updateNode(child, update));
+
+ return newRootNode;
+}
\ No newline at end of file
diff --git a/src/components/ChatbotFlowView/index.tsx b/src/components/ChatbotFlowView/index.tsx
new file mode 100644
index 0000000..1e39da0
--- /dev/null
+++ b/src/components/ChatbotFlowView/index.tsx
@@ -0,0 +1,142 @@
+'use client'
+
+import Skeleton from "react-loading-skeleton";
+import { ArrowRightIcon, ChatBubbleBottomCenterTextIcon, ChevronDownIcon, ChevronRightIcon, EnvelopeIcon, FilmIcon, PlusIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import { Button, DropdownMenu, DropdownMenuItem, Input } from "../Form";
+import { useChatbotFlow } from "./hooks";
+
+
+export function ChatbotFlowView() {
+
+ const {
+ ready,
+ currentNode,
+ nodesIndex,
+ nodesPath,
+ visible,
+ handleToggleDropdown,
+ handleNext,
+ handlePrevious,
+ handleAddResponse,
+ handleShowAddChild,
+ handleRemoveChild,
+ handlePatternChange,
+ handleOutputContentChange,
+ handleRemoveOutput,
+ handleSave,
+ } = useChatbotFlow();
+
+ if (!ready)
+ return ;
+
+ return (
+
+
+
+
+ {nodesPath.map((nodeId, index) => {
+
+ const node = nodesIndex[nodeId];
+
+ return (
+ <>
+ {index > 0 && }
+ -
+ {node.id === currentNode?.id ? node.label : {node.label}}
+
+ >
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nova resposta
+
+
+ }>
+
+ Texto
+
+
+ Image/Vídeo/Audio
+
+
+
+
+ {currentNode?.output.map((output) =>
+
+
+ {output.type === 'text' && Mensagem de texto}
+ {output.type === 'media-link' && Link multimidia}
+
+
+
{output.content}
+
)}
+
+
+
+
+ {currentNode?.childs.length === 0 && - Fim da conversa. Clique no botão + para continuar o diálogo.
}
+ {currentNode?.childs.map(node => { console.log(node); return (
+ -
+
+
+
+ )})}
+
+
+
+
+ );
+}
+ChatbotFlowView.Skeleton = function ChatbotFlowViewSkeleton() {
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Form/DropdownMenu/hooks/index.ts b/src/components/Form/DropdownMenu/hooks/index.ts
index 6cbe615..2b4d5b8 100644
--- a/src/components/Form/DropdownMenu/hooks/index.ts
+++ b/src/components/Form/DropdownMenu/hooks/index.ts
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { MouseEvent, useMemo } from "react";
import { useSnapshot } from "valtio";
import { nanoid } from "nanoid/non-secure";
import { dropdownMenuState } from "../state";
@@ -10,14 +10,33 @@ export function useDrodownMenu(context: string) {
const visible = activeMenu[context] === id;
+ const setVisible = (isVisible: boolean) => {
+
+ if (isVisible)
+ dropdownMenuState.activeMenu[context] = id;
+ else
+ dropdownMenuState.activeMenu[context] = null;
+ };
+
+ const handleToggleDropdown = (event: MouseEvent) => {
+
+ event.stopPropagation();
+ event.preventDefault();
+ setVisible(!visible);
+ };
+
+ const handleDropdownItemClick = (event: MouseEvent) => {
+
+ event.stopPropagation();
+ event.preventDefault();
+ setVisible(false);
+ };
+
+
return {
visible,
- setVisible: (isVisible: boolean): void => {
-
- if (isVisible)
- dropdownMenuState.activeMenu[context] = id;
- else
- dropdownMenuState.activeMenu[context] = null;
- }
+ setVisible,
+ handleToggleDropdown,
+ handleDropdownItemClick
};
}
\ No newline at end of file
diff --git a/src/components/Modal/Prompt/index.tsx b/src/components/Modal/Prompt/index.tsx
new file mode 100644
index 0000000..099b926
--- /dev/null
+++ b/src/components/Modal/Prompt/index.tsx
@@ -0,0 +1,35 @@
+import { ReactNode, useRef } from "react";
+import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import { Button, Input } from "../../Form";
+import { Modal, ModalBody, ModalHeader } from "..";
+
+
+export function PromptModal({ title, message, defaultValue, btnConfirmText, btnConfirmCancel, onDone }: PromptModalProps) {
+
+ const inputRef = useRef(null);
+ btnConfirmText ??= <> Confirmar>;
+ btnConfirmCancel ??= <> Cancelar>;
+
+ return (
+
+ {title}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export interface PromptModalProps {
+ title: string;
+ message: string;
+ defaultValue?: string;
+ btnConfirmText?: ReactNode;
+ btnConfirmCancel?: ReactNode;
+ onDone: (confirmed: boolean, value: string | null) => void | Promise;
+}
\ No newline at end of file
diff --git a/src/domains/message-providers/client-api.ts b/src/domains/message-providers/client-api.ts
index a17a934..eb373e8 100644
--- a/src/domains/message-providers/client-api.ts
+++ b/src/domains/message-providers/client-api.ts
@@ -1,7 +1,7 @@
import { HttpClient } from "@/common/http/client";
import { HttpClientFactory } from "@/common/http/client/factory";
import { UrlFormatter } from "@/common/http/url/url-formatter";
-import { Provider, ProviderWithStatus } from "@/common/services/messaging/models";
+import { ChatNode, Provider, ProviderWithStatus } from "@/common/services/messaging/models";
export class MessageProvidersApi {
@@ -44,4 +44,21 @@ export class MessageProvidersApi {
const url = UrlFormatter.format('{id}/finalize', { id: args.id });
await this._client.post(url, null);
}
+
+ public async getChatbotFlow(args: { id : number }): Promise {
+
+ const url = UrlFormatter.format('{id}/chatbot-flow', { id: args.id });
+ return await this._client.get(url) ?? null;
+ }
+
+ public async updateChatbotFlow({ providerId, ...args }: UpdateChatbotFlowInput): Promise {
+
+ const url = UrlFormatter.format('{id}/chatbot-flow', { id: providerId });
+ await this._client.put(url, args)
+ }
+}
+
+export interface UpdateChatbotFlowInput {
+ providerId: number;
+ chatbotFlow: ChatNode;
}
\ No newline at end of file
diff --git a/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts b/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts
new file mode 100644
index 0000000..f49a457
--- /dev/null
+++ b/src/domains/message-providers/use-cases/get-provider-chatbot-flow.ts
@@ -0,0 +1,48 @@
+import { PrismaClient } from "@prisma/client";
+import { UseCase } from "@/common/use-cases";
+import { ProvidersApi } from "@/common/services/messaging";
+import { ChatNode, ProviderWithStatus } from "@/common/services/messaging/models";
+import { UserLogged } from "@/common/auth/user";
+import { AppError } from "@/common/error";
+
+
+export class GetProviderChatbotFlow implements UseCase {
+
+ private _user: UserLogged;
+ private _db: PrismaClient;
+
+ constructor({ userLogged, prismaClient }: { userLogged: UserLogged, prismaClient: PrismaClient }) {
+
+ this._user = userLogged;
+ this._db = prismaClient;
+ }
+
+ public async execute(input: GetProviderChatbotFlowInput): Promise {
+
+ const account = await this._db.account.findFirstOrThrow({
+ where: {
+ id: this._user.accountId
+ },
+ select: {
+ messagingApiToken: true
+ }
+ });
+
+ if (!account?.messagingApiToken)
+ throw new AppError('Messaging not configured');
+
+ const api = new ProvidersApi({
+ accessToken: account?.messagingApiToken
+ });
+
+ const result = await api.getChatbotFlow({
+ id: input.providerId
+ });
+
+ return result;
+ }
+}
+
+export interface GetProviderChatbotFlowInput {
+ providerId: number;
+}
\ No newline at end of file
diff --git a/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts b/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts
new file mode 100644
index 0000000..b74c8c7
--- /dev/null
+++ b/src/domains/message-providers/use-cases/update-provider-chatbot-flow.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+import { PrismaClient } from "@prisma/client";
+import { UseCase } from "@/common/use-cases";
+import { ProvidersApi } from "@/common/services/messaging";
+import { ChatNode } from "@/common/services/messaging/models";
+import { UserLogged } from "@/common/auth/user";
+import { AppError } from "@/common/error";
+
+
+export class UpdateProviderChatbotFlow implements UseCase {
+
+ private _user: UserLogged;
+ private _db: PrismaClient;
+
+ constructor({ userLogged, prismaClient }: { userLogged: UserLogged, prismaClient: PrismaClient }) {
+
+ this._user = userLogged;
+ this._db = prismaClient;
+ }
+
+ public async execute(input: UpdateProviderChatbotFlowInput): Promise {
+
+ const account = await this._db.account.findFirstOrThrow({
+ where: {
+ id: this._user.accountId
+ },
+ select: {
+ messagingApiToken: true
+ }
+ });
+
+ if (!account?.messagingApiToken)
+ throw new AppError('Messaging not configured');
+
+ const api = new ProvidersApi({
+ accessToken: account?.messagingApiToken
+ });
+
+ await api.updateChatbotFlow({
+ id: input.providerId,
+ chatbotFlow: input.chatbotFlow
+ });
+ }
+}
+
+export interface UpdateProviderChatbotFlowInput {
+ providerId: number;
+ chatbotFlow: ChatNode;
+}
+
+export const updateProviderChatbotFlowInputSchema = z.object({
+ chatbotFlow: z.any()
+});
\ No newline at end of file