feat: implement WhatsApp bot setup flow in Assistant dashboard

This commit is contained in:
2026-07-24 14:10:26 -03:00
parent e6662aac18
commit 6c75929e21
3 changed files with 569 additions and 3 deletions
@@ -85,6 +85,10 @@ export default function OrganizationEdit() {
return;
}
if (wapView?.botSession === "ok") {
return;
}
wapServerBotGetQR({
companyId: id,
sessionUser: SessionInfo.userId,
@@ -17,6 +17,7 @@ import ColorLensIcon from '@mui/icons-material/ColorLens';
import PinDropIcon from '@mui/icons-material/PinDrop';
import ReviewsIcon from '@mui/icons-material/Reviews';
import NotificationsIcon from '@mui/icons-material/Notifications';
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
import API from "@services/Api.Service";
@@ -81,10 +82,19 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
flowId: "notifications",
title: "Notificaciones",
description: "Configura canales de notificación, recordatorios y horario silencioso para la organización o un cliente.",
category: "Negocio",
category: "Comunicación",
keywords: ["notificaciones", "canales", "whatsapp", "email", "sistema", "recordatorio", "silencioso", "cliente"],
icon: NotificationsIcon
},
{
id: "whatsapp-bot",
flowId: "whatsapp-bot",
title: "WhatsApp",
description: "Configura tu bot de WhatsApp para enviar notificaciones automáticas a tus clientes.",
category: "Comunicación",
keywords: ["whatsapp", "bot", "notificaciones", "wap", "mensajes", "automatico", "qr"],
icon: WhatsAppIcon
},
{
id: "new-service",
flowId: "onboarding-service&action=new-service",
@@ -26,6 +26,7 @@ import Switch from "@components/Switch/Switch";
import TextTime from "@components/TextTime/TextTime";
import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model";
import { OrganizationClientView } from "@models/Clients.model";
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
interface StepEngineProps {
flowId: string;
@@ -149,6 +150,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
// State for WhatsApp Bot Flow
const [wapView, setWapView] = useState<any>(null);
const [wapAction, setWapAction] = useState<string | null>(null);
const [wapPhase, setWapPhase] = useState<string>("intro");
const [wapQr, setWapQr] = useState<string | null>(null);
const [wapError, setWapError] = useState<string | null>(null);
// State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
const [setupMissingServices, setSetupMissingServices] = useState(false);
@@ -319,11 +327,16 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setNotifClients(res?.data || []);
})
.catch(err => console.error("Error cargando clientes de la organización:", err));
} else if (flowId === "whatsapp-bot") {
setWapPhase("intro");
setWapView(null);
setWapQr(null);
setWapError(null);
}
};
React.useEffect(() => {
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") {
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") {
setIsResuming(true);
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
.then(response => {
@@ -344,7 +357,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
.finally(() => setIsResuming(false));
}
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || (flowId === "onboarding-org" && action === "new-org")) {
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || flowId === "whatsapp-bot" || (flowId === "onboarding-org" && action === "new-org")) {
setCurrentStepIndex(0);
}
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
@@ -638,6 +651,543 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}
};
// --- WhatsApp Bot Flow Helpers ---
const getImageFromBase64 = (base64: string): string => {
const base64Data = base64.includes("base64,") ? base64.split(",")[1] : base64;
const byteCharacters = atob(base64Data);
const byteArray = new Uint8Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "image/png" });
return URL.createObjectURL(blob);
};
const loadWapView = async () => {
if (!createdCompanyId) return;
setWapAction("load");
setWapError(null);
try {
const view = await API.post<any>("views/wap-server-view", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
setWapView(view);
const hasPlan = SessionInfo.organizationSubscriptions?.[createdCompanyId]?.plan?.wapNotifications;
if (!hasPlan) {
setWapPhase("plan-required");
} else if (!view?.serverId) {
setWapPhase("assign-server");
} else if (!view?.exists) {
setWapPhase("create-bot");
} else if (view?.runningState && !String(view.runningState).includes("Up")) {
setWapPhase("start-bot");
} else if (view?.runningState && String(view.runningState).includes("Up") && view?.botSession !== "ok") {
setWapPhase("scan-qr");
try {
const qrData = await API.post<string>("wapserver/qr", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
setWapQr(qrData);
} catch {
setWapQr(null);
}
} else if (view?.runningState && String(view.runningState).includes("Up") && view?.botSession === "ok") {
setWapPhase("bot-ready");
}
} catch (err: any) {
console.error("Error loading WAP view:", err);
setWapError(err?.message || "Error al verificar el estado del bot.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapAssignServer = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("assign");
setWapError(null);
try {
await API.post<any>("wapserver/assign", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 2000));
await loadWapView();
} catch (err: any) {
console.error("Error assigning server:", err);
setWapError(err?.message || "Error al asignar el servidor.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapCreateBot = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("create");
setWapError(null);
try {
await API.post<any>("wapserver/create", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 5000));
await loadWapView();
} catch (err: any) {
console.error("Error creating bot:", err);
setWapError(err?.message || "Error al crear el bot.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapStartBot = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("start");
setWapError(null);
try {
await API.post<any>("wapserver/start", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 3000));
await loadWapView();
} catch (err: any) {
console.error("Error starting bot:", err);
setWapError(err?.message || "Error al iniciar el bot.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapVerifyQr = async () => {
if (!createdCompanyId) return;
setWapAction("verify");
try {
const view = await API.post<any>("views/wap-server-view", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
setWapView(view);
if (view?.botSession === "ok") {
setIsSuccess(true);
} else {
setWapError("Aún no se detectó la conexión. Asegurate de haber escaneado el QR y volvé a intentar.");
}
} catch (err: any) {
setWapError(err?.message || "Error al verificar la conexión.");
} finally {
setWapAction(null);
}
};
const handleWapStopBot = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("stop");
setWapError(null);
try {
await API.post<any>("wapserver/stop", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 2000));
await loadWapView();
} catch (err: any) {
console.error("Error stopping bot:", err);
setWapError(err?.message || "Error al detener el bot.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapDeleteBot = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("delete");
setWapError(null);
try {
await API.post<any>("wapserver/delete", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 3000));
await loadWapView();
} catch (err: any) {
console.error("Error deleting bot:", err);
setWapError(err?.message || "Error al eliminar el bot.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const handleWapQuitServer = async () => {
if (!createdCompanyId || wapAction) return;
setWapAction("quit");
setWapError(null);
try {
await API.post<any>("wapserver/quit", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
await new Promise(resolve => setTimeout(resolve, 2000));
await loadWapView();
} catch (err: any) {
console.error("Error quitting server:", err);
setWapError(err?.message || "Error al desasignar el servidor.");
setWapPhase("error");
} finally {
setWapAction(null);
}
};
const renderWhatsAppBotFlow = () => {
return (
<>
{/* Intro */}
<QuestionCard
isActive={wapPhase === "intro"}
title="Bot de WhatsApp"
description="Vamos a configurar tu bot de WhatsApp. Esto te permitirá enviar notificaciones automáticas a tus clientes. Primero verificamos el estado actual de tu organización."
onNext={() => {
loadWapView();
}}
nextLabel="Verificar estado"
topAccessory={
<WhatsAppIcon style={{ fontSize: 48, color: "#25D366" }} />
}
>
{null}
</QuestionCard>
{/* Plan required */}
<QuestionCard
isActive={wapPhase === "plan-required"}
title="Plan no compatible"
description="Tu plan actual no incluye notificaciones por WhatsApp. Necesitás un plan que incluya esta funcionalidad para configurar el bot."
onNext={() => {
router.push(`/landing/upgrade-plan?org=${createdCompanyId}`);
}}
nextLabel="Ver planes"
topAccessory={
<div style={{ fontSize: 48 }}>📦</div>
}
>
<div style={{
padding: "14px",
borderRadius: "12px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
color: "rgba(255,255,255,0.7)",
fontSize: "13px",
lineHeight: 1.5
}}>
Actualizá tu plan para desbloquear notificaciones por WhatsApp y mejorar la comunicación con tus clientes.
</div>
</QuestionCard>
{/* Assign server */}
<QuestionCard
isActive={wapPhase === "assign-server"}
title="Asignar servidor"
description="Tu organización necesita un servidor de WhatsApp. Vamos a asignar uno automáticamente."
onNext={handleWapAssignServer}
disableNext={wapAction !== null}
nextLabel={wapAction === "assign" ? "Asignando servidor..." : "Asignar servidor"}
topAccessory={
<div style={{ fontSize: 48 }}>🖥</div>
}
>
{wapAction === "assign" && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px", padding: "20px 0" }}>
<div style={{
width: 36, height: 36, border: "3px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
<span style={{ color: "rgba(255,255,255,0.7)", fontSize: "14px" }}>Asignando servidor...</span>
</div>
)}
</QuestionCard>
{/* Create bot */}
<QuestionCard
isActive={wapPhase === "create-bot"}
title="Crear bot de WhatsApp"
description={`Servidor asignado: ${wapView?.serverName || "disponible"}. Ahora vamos a crear tu bot de WhatsApp.`}
onNext={handleWapCreateBot}
disableNext={wapAction !== null}
nextLabel={wapAction === "create" ? "Creando bot..." : "Crear bot de WhatsApp"}
topAccessory={
<div style={{ fontSize: 48 }}>🤖</div>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
{wapAction === "create" && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px", padding: "20px 0" }}>
<div style={{
width: 36, height: 36, border: "3px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
<span style={{ color: "rgba(255,255,255,0.7)", fontSize: "14px" }}>Creando bot...</span>
</div>
)}
<button
type="button"
onClick={handleWapQuitServer}
disabled={wapAction !== null}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
padding: "12px 20px", borderRadius: "10px", width: "100%",
background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.15)",
color: "rgba(255,255,255,0.7)", cursor: "pointer", fontSize: "14px", fontWeight: 600,
fontFamily: "inherit", transition: "all 0.2s ease", marginTop: "4px"
}}
>
{wapAction === "quit" ? (
<>
<div style={{
width: 18, height: 18, border: "2px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
Desasignando servidor...
</>
) : "Desasignar servidor"}
</button>
</div>
</QuestionCard>
{/* Start bot */}
<QuestionCard
isActive={wapPhase === "start-bot"}
title="Iniciar bot"
description="Tu bot de WhatsApp está creado pero detenido. Vamos a iniciarlo."
onNext={handleWapStartBot}
disableNext={wapAction !== null}
nextLabel={wapAction === "start" ? "Iniciando bot..." : "Iniciar bot"}
topAccessory={
<div style={{ fontSize: 48 }}></div>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
{wapAction === "start" && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px", padding: "20px 0" }}>
<div style={{
width: 36, height: 36, border: "3px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
<span style={{ color: "rgba(255,255,255,0.7)", fontSize: "14px" }}>Iniciando bot...</span>
</div>
)}
<button
type="button"
onClick={handleWapDeleteBot}
disabled={wapAction !== null}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
padding: "12px 20px", borderRadius: "10px", width: "100%",
background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.15)",
color: "rgba(255,255,255,0.7)", cursor: "pointer", fontSize: "14px", fontWeight: 600,
fontFamily: "inherit", transition: "all 0.2s ease", marginTop: "4px"
}}
>
{wapAction === "delete" ? (
<>
<div style={{
width: 18, height: 18, border: "2px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
Eliminando bot...
</>
) : "Eliminar bot"}
</button>
</div>
</QuestionCard>
{/* Scan QR */}
<QuestionCard
isActive={wapPhase === "scan-qr"}
title="Escanear código QR"
description="Tu bot está funcionando. Ahora necesitás vincular tu número de WhatsApp escaneando este código QR con tu teléfono."
onNext={handleWapVerifyQr}
disableNext={wapAction !== null}
nextLabel={wapAction === "verify" ? "Verificando..." : "Ya escaneé el código"}
topAccessory={
<div style={{ fontSize: 48 }}>📱</div>
}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "16px", width: "100%" }}>
{wapQr ? (
<div style={{
padding: "16px",
borderRadius: "12px",
background: "white",
boxShadow: "0 4px 20px rgba(0,0,0,0.3)"
}}>
<img
src={getImageFromBase64(wapQr)}
width={250}
height={250}
alt="Escaneá el código QR"
style={{ display: "block" }}
/>
</div>
) : (
<div style={{
width: 250, height: 250, borderRadius: "12px",
background: "rgba(255,255,255,0.05)",
border: "2px dashed rgba(255,255,255,0.2)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "rgba(255,255,255,0.5)", fontSize: "14px"
}}>
Cargando QR...
</div>
)}
<div style={{
padding: "12px 16px",
borderRadius: "10px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
color: "rgba(255,255,255,0.7)",
fontSize: "13px",
lineHeight: 1.5,
textAlign: "center",
maxWidth: "350px"
}}>
Abrí WhatsApp en tu teléfono <strong style={{ color: "white" }}>Configuración</strong> <strong style={{ color: "white" }}>Dispositivos vinculados</strong> <strong style={{ color: "white" }}>Vincular dispositivo</strong>
</div>
{wapError && (
<div style={{
padding: "12px 16px",
borderRadius: "10px",
background: "rgba(255,60,60,0.1)",
border: "1px solid rgba(255,60,60,0.3)",
color: "#ff9999",
fontSize: "13px",
textAlign: "center",
maxWidth: "350px"
}}>
{wapError}
</div>
)}
<button
type="button"
onClick={handleWapStopBot}
disabled={wapAction !== null}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
padding: "12px 20px", borderRadius: "10px", width: "100%",
background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.15)",
color: "rgba(255,255,255,0.7)", cursor: "pointer", fontSize: "14px", fontWeight: 600,
fontFamily: "inherit", transition: "all 0.2s ease", marginTop: "4px"
}}
>
{wapAction === "stop" ? (
<>
<div style={{
width: 18, height: 18, border: "2px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
Deteniendo bot...
</>
) : "Detener bot"}
</button>
</div>
</QuestionCard>
{/* Bot ready */}
<QuestionCard
isActive={wapPhase === "bot-ready"}
title="¡Bot funcionando!"
description="Tu bot de WhatsApp está conectado y funcionando correctamente. Ya puede enviar notificaciones a tus clientes."
onNext={() => setIsSuccess(true)}
nextLabel="Finalizar"
topAccessory={
<div style={{ fontSize: 48 }}></div>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<div style={{
padding: "14px", borderRadius: "12px",
background: "rgba(74,222,128,0.08)", border: "1px solid rgba(74,222,128,0.2)",
color: "#4ade80", fontSize: "13px", textAlign: "center", fontWeight: 600
}}>
Sesión de WhatsApp activa Bot operativo
</div>
<button
type="button"
onClick={handleWapStopBot}
disabled={wapAction !== null}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
padding: "12px 20px", borderRadius: "10px", width: "100%",
background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.15)",
color: "rgba(255,255,255,0.7)", cursor: "pointer", fontSize: "14px", fontWeight: 600,
fontFamily: "inherit", transition: "all 0.2s ease", marginTop: "4px"
}}
>
{wapAction === "stop" ? (
<>
<div style={{
width: 18, height: 18, border: "2px solid rgba(255,255,255,0.2)",
borderTopColor: "var(--wine-red)", borderRadius: "50%",
animation: "spin 1s linear infinite"
}} />
Deteniendo bot...
</>
) : "Detener bot"}
</button>
</div>
</QuestionCard>
{/* Error */}
<QuestionCard
isActive={wapPhase === "error"}
title="Error"
description={wapError || "Ocurrió un error inesperado. Por favor, intentá nuevamente."}
onNext={() => {
setWapPhase("intro");
setWapError(null);
}}
nextLabel="Reintentar"
topAccessory={
<div style={{ fontSize: 48 }}></div>
}
>
<div style={{
padding: "14px",
borderRadius: "12px",
background: "rgba(255,60,60,0.1)",
border: "1px solid rgba(255,60,60,0.3)",
color: "#ff9999",
fontSize: "13px",
lineHeight: 1.5
}}>
Si el problema persiste, contactá al soporte de TurnosXpress.
</div>
</QuestionCard>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</>
);
};
const renderOnboardingOrgFlow = () => {
return (
<>
@@ -3945,6 +4495,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
if (flowId === "new-collaborator") return renderNewCollaboratorFlow();
if (flowId === "public-visibility") return renderPublicVisibilityFlow();
if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
if (flowId === "update-schedule") return renderUpdateScheduleFlow();
@@ -3964,6 +4515,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
if (flowId === "new-collaborator") return "El colaborador fue invitado exitosamente.";
if (flowId === "setup-collaborator") return "El colaborador ha sido configurado exitosamente y ya puede comenzar a recibir reservas.";
if (flowId === "notifications") return "La configuración de notificaciones se ha guardado exitosamente.";
if (flowId === "whatsapp-bot") return "Tu bot de WhatsApp está configurado y funcionando. Tus clientes recibirán notificaciones automáticas por WhatsApp.";
if (action) return "Tus cambios se han guardado exitosamente.";
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
};