From c77d479344a48d3962afa10a9e7f7e38bec8b166 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Tue, 28 Jul 2026 20:01:59 -0300 Subject: [PATCH] feat: implement StepEngine architecture for assistant onboarding and configuration flows --- .../components/StepEngine/StepEngine.tsx | 4426 +++-------------- .../components/OrganizationPicker.tsx | 38 + .../StepEngine/components/SuccessScreen.tsx | 79 + .../StepEngine/constants/notifications.ts | 18 + .../constants/reservationPeriods.ts | 9 + .../StepEngine/flows/DisableScheduleFlow.tsx | 292 ++ .../StepEngine/flows/NewCollaboratorFlow.tsx | 100 + .../StepEngine/flows/NotificationsFlow.tsx | 366 ++ .../StepEngine/flows/OnboardingOrgFlow.tsx | 478 ++ .../flows/OnboardingScheduleFlow.tsx | 381 ++ .../flows/OnboardingServiceFlow.tsx | 224 + .../StepEngine/flows/OverrideScheduleFlow.tsx | 218 + .../StepEngine/flows/PublicLinkFlow.tsx | 92 + .../StepEngine/flows/PublicVisibilityFlow.tsx | 74 + .../flows/ReservationPeriodFlow.tsx | 112 + .../flows/SetupCollaboratorFlow.tsx | 155 + .../StepEngine/flows/UpdateScheduleFlow.tsx | 234 + .../StepEngine/flows/WhatsAppBotFlow.tsx | 116 + .../StepEngine/helpers/employees.ts | 26 + .../components/StepEngine/helpers/images.ts | 10 + .../StepEngine/helpers/publicLink.ts | 16 + .../StepEngine/helpers/schedules.ts | 26 + .../components/StepEngine/helpers/time.ts | 6 + .../hooks/useNewCollaboratorHandlers.ts | 111 + .../hooks/useNotificationsHandlers.ts | 117 + .../hooks/useReservationPeriodHandlers.ts | 55 + .../hooks/useWhatsAppBotHandlers.ts | 124 + 27 files changed, 4036 insertions(+), 3867 deletions(-) create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/components/OrganizationPicker.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/constants/notifications.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/constants/reservationPeriods.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/DisableScheduleFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/NewCollaboratorFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/NotificationsFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingOrgFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingScheduleFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/OverrideScheduleFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/PublicLinkFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/PublicVisibilityFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/ReservationPeriodFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/SetupCollaboratorFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/flows/WhatsAppBotFlow.tsx create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/helpers/employees.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/helpers/images.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/helpers/publicLink.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/helpers/time.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/hooks/useNewCollaboratorHandlers.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/hooks/useNotificationsHandlers.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/hooks/useReservationPeriodHandlers.ts create mode 100644 txclient/src/app/admin/assistant/components/StepEngine/hooks/useWhatsAppBotHandlers.ts diff --git a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx index e7ee9ff..fb0405d 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx @@ -3,12 +3,7 @@ import React, { useState } from "react"; import style from "./StepEngine.module.css"; import { useRouter } from "next/navigation"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import SearchIcon from '@mui/icons-material/Search'; -import QuestionCard from "../QuestionCard/QuestionCard"; -import QuestionInput from "../QuestionCard/QuestionInput"; -import ClientPicker from "../ClientPicker/ClientPicker"; import { useSessionStore } from "@core/Store/Sesion.Store"; -import ReactAvatar from "react-avatar"; import API from "@services/Api.Service"; import dayjs from "dayjs"; import "dayjs/locale/es"; @@ -16,19 +11,35 @@ import { getMondayWeekStart, getSundayWeekEnd } from "@core/helpers/week"; dayjs.locale("es"); -import categories from "@models/Categories.type"; -import OnboardingPhone from "../OnboardingPhone/OnboardingPhone"; -import OnboardingLocation, { OnboardingLocationData } from "../OnboardingLocation/OnboardingLocation"; -import SelectLocationMap from "@core/app/components/SelectLocationMap/SelectLocationMap"; -import { validatePhone } from "@core/helpers/validatePhone"; -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"; -import QrCode2Icon from "@mui/icons-material/QrCode2"; -import { getSlug } from "@core/app/helpers/Slug"; import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; +import OnboardingOrgFlow from "./flows/OnboardingOrgFlow"; +import OnboardingServiceFlow from "./flows/OnboardingServiceFlow"; +import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow"; +import PublicLinkFlow from "./flows/PublicLinkFlow"; +import WhatsAppBotFlow from "./flows/WhatsAppBotFlow"; +import UpdateScheduleFlow from "./flows/UpdateScheduleFlow"; +import OverrideScheduleFlow from "./flows/OverrideScheduleFlow"; +import DisableScheduleFlow from "./flows/DisableScheduleFlow"; +import PublicVisibilityFlow from "./flows/PublicVisibilityFlow"; +import NotificationsFlow from "./flows/NotificationsFlow"; +import NewCollaboratorFlow from "./flows/NewCollaboratorFlow"; +import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow"; +import ReservationPeriodFlow from "./flows/ReservationPeriodFlow"; +import SuccessScreen from "./components/SuccessScreen"; +import OrganizationPicker from "./components/OrganizationPicker"; +import { reservationPeriodOptions } from "./constants/reservationPeriods"; +import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications"; +import { getEmployeeId, getEmployeeUserId, getTargetEmployees } from "./helpers/employees"; +import { buildScheduleBlocks, getWorkingDays } from "./helpers/schedules"; +import { getPublicLink as buildPublicLink, getPublicLinkQrUrl as buildPublicLinkQrUrl, getSelectedOrganizationSlug as buildSelectedOrganizationSlug } from "./helpers/publicLink"; +import { dayjsFromHHmm } from "./helpers/time"; +import { getImageFromBase64 } from "./helpers/images"; +import { useWhatsAppBotHandlers } from "./hooks/useWhatsAppBotHandlers"; +import { useNotificationsHandlers } from "./hooks/useNotificationsHandlers"; +import { useReservationPeriodHandlers } from "./hooks/useReservationPeriodHandlers"; +import { useNewCollaboratorHandlers } from "./hooks/useNewCollaboratorHandlers"; interface StepEngineProps { flowId: string; @@ -146,13 +157,6 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [disableStartTime, setDisableStartTime] = useState(""); const [disableEndTime, setDisableEndTime] = useState(""); const [reservationPeriodScope, setReservationPeriodScope] = useState<"employee" | "all" | null>(null); - const reservationPeriodOptions: Array = [ - { unit: "week", value: 1, label: "1 semana" }, - { unit: "week", value: 2, label: "2 semanas" }, - { unit: "week", value: 3, label: "3 semanas" }, - { unit: "month", value: 1, label: "1 mes" }, - { unit: "month", value: 2, label: "2 meses" }, - ]; const [reservationPeriodDuration, setReservationPeriodDuration] = useState({ unit: "month", value: 1 }); const [reservationPeriodSummary, setReservationPeriodSummary] = useState(null); const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false); @@ -181,8 +185,6 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [setupSelectedServices, setSetupSelectedServices] = useState([]); const [setupAvailableServices, setSetupAvailableServices] = useState([]); - const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId; - const canManageEmployeeSchedules = (employee: any) => { const isCurrentUser = String(getEmployeeUserId(employee)) === String(SessionInfo.userId); return !employee.removed && (isCurrentUser || employee.guestOk === true); @@ -269,7 +271,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm API.post("employees/get-by-company", { companyId: org.id || org._id }) .then(empRes => { if (empRes && empRes.length > 0) { - const foundEmployeeId = empRes[0].id || empRes[0]._id || empRes[0].employeeId; + const foundEmployeeId = getEmployeeId(empRes[0]); setEmployeeId(foundEmployeeId); API.post("schedules/get-all-by-collaborator", { companyId: org.id || org._id, @@ -309,7 +311,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } if (availableEmployees.length === 1) { setReservationPeriodScope("employee"); - setTargetEmployeeId(availableEmployees[0].id || availableEmployees[0]._id || availableEmployees[0].employeeId); + setTargetEmployeeId(getEmployeeId(availableEmployees[0])); } } }).catch(error => { @@ -710,946 +712,115 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - // --- WhatsApp Bot Flow Helpers --- + const { + loadWapView, + handleWapAssignServer, + handleWapCreateBot, + handleWapStartBot, + handleWapStopBot, + handleWapDeleteBot, + handleWapQuitServer, + handleWapVerifyQr + } = useWhatsAppBotHandlers({ + createdCompanyId, + sessionUserId: SessionInfo.userId, + organizationSubscriptions: SessionInfo.organizationSubscriptions, + wapAction, + setWapAction, + setWapError, + setWapPhase, + setWapQr, + setWapView, + setIsSuccess + }); - 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 renderWhatsAppBotFlow = () => ( + + ); - const loadWapView = async () => { - if (!createdCompanyId) return; - setWapAction("load"); - setWapError(null); - try { - const view = await API.post("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("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("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("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("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("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("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("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("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 */} - { - loadWapView(); - }} - nextLabel="Verificar estado" - topAccessory={ - - } - > - {null} - - - {/* Plan required */} - { - router.push(`/landing/upgrade-plan?org=${createdCompanyId}`); - }} - nextLabel="Ver planes" - topAccessory={ -
📦
- } - > -
- Actualizá tu plan para desbloquear notificaciones por WhatsApp y mejorar la comunicación con tus clientes. -
-
- - {/* Assign server */} - 🖥️ - } - > - {wapAction === "assign" && ( -
-
- Asignando servidor... -
- )} - - - {/* Create bot */} - 🤖
- } - > -
- {wapAction === "create" && ( -
-
- Creando bot... -
- )} - -
- - - {/* Start bot */} - ▶️
- } - > -
- {wapAction === "start" && ( -
-
- Iniciando bot... -
- )} - -
- - - {/* Scan QR */} - 📱
- } - > -
- {wapQr ? ( -
- Escaneá el código QR -
- ) : ( -
- Cargando QR... -
- )} -
- Abrí WhatsApp en tu teléfono → ConfiguraciónDispositivos vinculadosVincular dispositivo -
- {wapError && ( -
- {wapError} -
- )} - -
-
- - {/* Bot ready */} - setIsSuccess(true)} - nextLabel="Finalizar" - topAccessory={ -
- } - > -
-
- Sesión de WhatsApp activa — Bot operativo -
- -
-
- - {/* Error */} - { - setWapPhase("intro"); - setWapError(null); - }} - nextLabel="Reintentar" - topAccessory={ -
⚠️
- } - > -
- Si el problema persiste, contactá al soporte de TurnosXpress. -
-
- - - - ); - }; - - const renderOnboardingOrgFlow = () => { - return ( - <> - setImageError(true)} - /> - ) : ( - - ) - } - > - {null} - - - - { - setOrgName(e.target.value); - if (nameError) setNameError(""); - }} - onKeyDown={(e) => { - if (orgName.trim().length >= 3) { - handleKeyDown(e, handleNextName); - } - }} - autoFocus - error={nameError} - /> - - - -
- {categories.filter(c => c.id !== 0).map(cat => ( -
{ - setCategoryId(cat.id); - setTimeout(handleNext, 400); - }} - > -
-
{cat.name}
-
- ))} -
-
- - - setOrgDescription(e.target.value)} - onKeyDown={(e) => { - if (orgDescription.trim().length >= 10) { - handleKeyDown(e, handleCreateCompany); - } - }} - autoFocus - /> - - -
- { - if (newData.street !== undefined) setStreet(newData.street); - if (newData.streetNumber !== undefined) setStreetNumber(newData.streetNumber); - if (newData.builingFloor !== undefined) setBuilingFloor(newData.builingFloor); - if (newData.buildingApartament !== undefined) setBuildingApartament(newData.buildingApartament); - if (newData.state !== undefined) setState(newData.state); - if (newData.city !== undefined) setCity(newData.city); - if (newData.block !== undefined) setBlock(newData.block); - if (newData.zipCode !== undefined) setZipCode(newData.zipCode); - if (newData.latitude !== undefined) setLatitude(newData.latitude); - if (newData.longitude !== undefined) setLongitude(newData.longitude); - }} - /> -
-
- - -
-
- { - setLatitude(loc.latitude); - setLongitude(loc.longitude); - }} - /> -
-
{ - if (!isCreating) handleUpdateLocation(); - }} - > - Omitir este paso -
-
-
- - -
- { - setPhoneAreaCode(area); - setPhoneNumber(num); - }} - onKeyDown={(e) => { - if (validatePhone(phoneAreaCode, phoneNumber).isValid) { - handleKeyDown(e, handleUpdateContact); - } - }} - /> -
-
- - -
-
-
Fracción de la Agenda (Minutos)
-
- {[15, 30, 45, 60, 120].map(val => ( -
setHeatMapFraction(val)} - style={{ - padding: '8px 16px', - borderRadius: '8px', - cursor: 'pointer', - background: heatMapFraction === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${heatMapFraction === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - fontWeight: heatMapFraction === val ? 'bold' : 'normal', - transition: 'all 0.2s ease' - }} - > - {val}m -
- ))} -
-
- -
-
Anticipación mínima para reservar (Horas)
-
- {[1, 2, 12, 24, 48].map(val => ( -
setAppointmentTime(val)} - style={{ - padding: '8px 16px', - borderRadius: '8px', - cursor: 'pointer', - background: appointmentTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${appointmentTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - fontWeight: appointmentTime === val ? 'bold' : 'normal', - transition: 'all 0.2s ease' - }} - > - {val}h -
- ))} -
-
- -
-
Anticipación mínima para cancelar (Horas)
-
- {[1, 2, 12, 24, 48].map(val => ( -
setCancellationTime(val)} - style={{ - padding: '8px 16px', - borderRadius: '8px', - cursor: 'pointer', - background: cancellationTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${cancellationTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - fontWeight: cancellationTime === val ? 'bold' : 'normal', - transition: 'all 0.2s ease' - }} - > - {val}h -
- ))} -
-
-
-
- - -
-
-
- - setHeaderColor(e.target.value)} - style={{ width: '100%', height: '40px', border: 'none', borderRadius: '8px', cursor: 'pointer' }} - /> -
-
- - setHeaderFontColor(e.target.value)} - style={{ width: '100%', height: '40px', border: 'none', borderRadius: '8px', cursor: 'pointer' }} - /> -
-
- - {/* PREVIEW BOX */} -
-
- Vista previa: -
-
- {headerImage && ( - Preview - )} -
- {orgName || "Tu Negocio"} -
-
- Categoría seleccionada -
-
-
- - -
-
- - ); - }; + const renderOnboardingOrgFlow = () => ( + + ); const handleCreateService = async () => { if (!createdCompanyId || !serviceName || !serviceDescription || !serviceLength || isCreating) return; @@ -1721,202 +892,29 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderOnboardingServiceFlow = () => { - const fraction = heatMapFraction || 60; - const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; - - return ( - <> - -
- Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil! -
-
- - -
- setServiceName(e.target.value)} - autoFocus - /> - setServiceDescription(e.target.value)} - onKeyDown={(e) => { - if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) { - handleKeyDown(e, handleNext); - } - }} - /> -
-
- - -
- {durationOptions.map(val => { - const hours = Math.floor(val / 60); - const minutes = val % 60; - const label = hours > 0 - ? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim() - : `${minutes} min`; - - return ( -
setServiceLength(val)} - style={{ - padding: '12px 24px', - borderRadius: '12px', - cursor: 'pointer', - background: serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, - color: 'white', - fontWeight: serviceLength === val ? 'bold' : 'normal', - transition: 'all 0.2s ease', - fontSize: '16px' - }} - > - {label} -
- ); - })} -
-
- - -
-
-
Precio Original
-
- $ - setServicePrice(e.target.value)} - style={{ - width: '100%', - padding: '15px 15px 15px 35px', - borderRadius: '12px', - border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.05)', - color: 'white', - fontSize: '18px', - outline: 'none' - }} - /> -
-
- -
-
Cupos Simultáneos (Por defecto 1)
-
- {[1, 2, 3, 5, 10].map(val => ( -
setServiceLimit(val)} - style={{ - padding: '8px 16px', - borderRadius: '8px', - cursor: 'pointer', - background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - fontWeight: serviceLimit === val ? 'bold' : 'normal', - transition: 'all 0.2s ease' - }} - > - {val} -
- ))} -
-
-
-
- - -
-
- {serviceImage ? ( - Preview - ) : ( - Toca para seleccionar imagen - )} - { - if (e.target.files && e.target.files.length > 0) { - setServiceImage(e.target.files[0]); - } - }} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - opacity: 0, - cursor: 'pointer' - }} - /> -
-
-
- - ); - }; + const renderOnboardingServiceFlow = () => ( + + ); const handleCreateSchedule = async () => { if (!createdCompanyId || isCreating) return; @@ -1967,29 +965,11 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } // 3. Create Schedules - let workingDays: number[] = []; - if (workingDaysMode === "mon-fri") workingDays = [1, 2, 3, 4, 5]; - else if (workingDaysMode === "mon-sat") workingDays = [1, 2, 3, 4, 5, 6]; - else if (workingDaysMode === "mon-sun") workingDays = [1, 2, 3, 4, 5, 6, 0]; + const workingDays = getWorkingDays(workingDaysMode); for (let i = 0; i <= 6; i++) { const isWorkingDay = workingDays.includes(i); - - let schedules: Array<{ from: string; to: string; disabled: boolean }> = []; - if (isWorkingDay) { - if (hasBreak) { - schedules = [ - { from: openTime, to: breakStart, disabled: false }, - { from: breakEnd, to: closeTime, disabled: false } - ]; - } else { - schedules = [ - { from: openTime, to: closeTime, disabled: false } - ]; - } - } else { - schedules = []; - } + const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : []; await API.post("schedules/update", { companyId: createdCompanyId, @@ -2064,365 +1044,51 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderOnboardingScheduleFlow = () => { - return ( - <> - -
- Con esta información, tu agenda online comenzará a aceptar reservas automáticamente. -
-
- - { - if (doesProvideServices === null) return; - if (!doesProvideServices) { - const query = createdCompanyId ? `&companyId=${createdCompanyId}` : ""; - router.push(`/admin/assistant?flow=new-collaborator${query}`); - } else { - handleNext(); - } - }} - disableNext={doesProvideServices === null} - nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"} - > -
-
setDoesProvideServices(true)} - style={{ - padding: '20px 24px', - borderRadius: '12px', - cursor: 'pointer', - background: doesProvideServices === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${doesProvideServices === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, - color: 'white', - transition: 'all 0.2s ease', - textAlign: 'center', - flex: '1', - minWidth: '200px' - }} - > -

Sí, yo atiendo a los clientes

-

Asignaremos los servicios a tu nombre.

-
-
setDoesProvideServices(false)} - style={{ - padding: '20px 24px', - borderRadius: '12px', - cursor: 'pointer', - background: doesProvideServices === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${doesProvideServices === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, - color: 'white', - transition: 'all 0.2s ease', - textAlign: 'center', - flex: '1', - minWidth: '200px' - }} - > -

No, tengo un equipo

-

Te guiaremos para invitar al primer colaborador.

-
-
-
- - -
- {[ - { value: "mon-fri", label: "Lunes a Viernes" }, - { value: "mon-sat", label: "Lunes a Sábado" }, - { value: "mon-sun", label: "Lunes a Domingo" }, - ].map(opt => ( -
setWorkingDaysMode(opt.value)} - style={{ - padding: '12px 24px', - borderRadius: '12px', - cursor: 'pointer', - background: workingDaysMode === opt.value ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${workingDaysMode === opt.value ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, - color: 'white', - fontWeight: workingDaysMode === opt.value ? 'bold' : 'normal', - transition: 'all 0.2s ease', - fontSize: '16px' - }} - > - {opt.label} -
- ))} -
-
- - -
-
- Apertura - setOpenTime(e.target.value)} - style={{ - padding: '12px', - borderRadius: '8px', - border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.05)', - color: 'white', - fontSize: '16px' - }} - /> -
- a -
- Cierre - setCloseTime(e.target.value)} - style={{ - padding: '12px', - borderRadius: '8px', - border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.05)', - color: 'white', - fontSize: '16px' - }} - /> -
-
-
- - -
-
-
setHasBreak(true)} - style={{ - padding: '10px 20px', - borderRadius: '8px', - cursor: 'pointer', - background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - transition: 'all 0.2s ease' - }} - > - Sí, tengo descanso -
-
setHasBreak(false)} - style={{ - padding: '10px 20px', - borderRadius: '8px', - cursor: 'pointer', - background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', - border: `1px solid ${hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, - color: 'white', - transition: 'all 0.2s ease' - }} - > - No, horario corrido -
-
- - {hasBreak && ( -
-
- Inicia el descanso - setBreakStart(e.target.value)} - style={{ - padding: '12px', - borderRadius: '8px', - border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.05)', - color: 'white', - fontSize: '16px' - }} - /> -
- a -
- Termina - setBreakEnd(e.target.value)} - style={{ - padding: '12px', - borderRadius: '8px', - border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.05)', - color: 'white', - fontSize: '16px' - }} - /> -
-
- )} -
-
- - -
- - {/* Mockup visual */} -
- {/* Cabecera Mockup */} -
- {/* Simular imagen de cabecera si hay Archivo */} - {headerImage && ( - - )} -

{orgName || "Tu Negocio"}

-
- - {/* Cuerpo Mockup (Servicios y Horarios) */} -
-
-

Servicios ({previewServices.length})

- -
-
- {previewServices.map((s, idx) => ( -
-
- {s.name} - {s.length} min -
- ${s.originalPrice} -
- ))} - {previewServices.length === 0 && ( - Sin servicios configurados aún. - )} -
- -

Horario Laboral

-
-
- Días: {workingDaysMode === "mon-fri" ? "Lunes a Viernes" : (workingDaysMode === "mon-sat" ? "Lunes a Sábado" : "Lunes a Domingo")} -
-
- Horario: {openTime} a {closeTime} -
- {hasBreak && ( -
- Descanso: {breakStart} a {breakEnd} -
- )} -
-
-
- - {/* Advertencia Explícita */} -
-

🚨 Aviso de Publicación

-

- Al hacer clic en Publicar y Activar Agenda, tu organización, servicios y esta primera jornada laboral serán publicados en TurnosXpress. -

- ¡Tus clientes podrán empezar a reservar turnos de inmediato! Podrás editar todo más tarde desde tu panel. -

-
-
-
- - ); - }; + const renderOnboardingScheduleFlow = () => ( + + ); const handleUpdateScheduleSubmit = async () => { if (!createdCompanyId || isCreating || !scheduleScope) return; setIsCreating(true); try { - let targetEmployees: any[] = []; - - if (scheduleScope === "me") { - const me = allEmployees.find((e: any) => { - const eUserId = e.userId?._id || e.userId?.id || e.userId; - return String(eUserId) === String(SessionInfo.userId); - }); - if (me) targetEmployees.push(me); - } else if (scheduleScope === "specific") { - const specific = allEmployees.find(e => e.id === targetEmployeeId || e._id === targetEmployeeId || e.employeeId === targetEmployeeId); - if (specific) targetEmployees.push(specific); - } else if (scheduleScope === "all") { - targetEmployees = [...allEmployees]; - } + const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); if (targetEmployees.length === 0) { alert("No se encontró ningún colaborador seleccionado."); @@ -2430,33 +1096,15 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm return; } - let workingDays: number[] = []; - if (workingDaysMode === "mon-fri") workingDays = [1, 2, 3, 4, 5]; - else if (workingDaysMode === "mon-sat") workingDays = [1, 2, 3, 4, 5, 6]; - else if (workingDaysMode === "mon-sun") workingDays = [1, 2, 3, 4, 5, 6, 0]; + const workingDays = getWorkingDays(workingDaysMode); for (const emp of targetEmployees) { - const empId = emp.id || emp._id || emp.employeeId; + const empId = getEmployeeId(emp); if (!empId) continue; for (let i = 0; i <= 6; i++) { const isWorkingDay = workingDays.includes(i); - - let schedules: Array<{ from: string; to: string; disabled: boolean }> = []; - if (isWorkingDay) { - if (hasBreak) { - schedules = [ - { from: openTime, to: breakStart, disabled: false }, - { from: breakEnd, to: closeTime, disabled: false } - ]; - } else { - schedules = [ - { from: openTime, to: closeTime, disabled: false } - ]; - } - } else { - schedules = []; - } + const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : []; await API.post("schedules/update", { companyId: createdCompanyId, @@ -2476,234 +1124,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderUpdateScheduleFlow = () => { - return ( - <> - { - if (scheduleScope === "specific" && !targetEmployeeId) { - alert("Por favor selecciona un colaborador."); - return; - } - handleNext(); - }} - disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} - > -
-
setScheduleScope("me")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

Solo a mí

-

Aplica esta rutina únicamente a tus horarios.

-
-
setScheduleScope("specific")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A un colaborador en particular

-

Aplica esta rutina a un integrante específico de tu equipo.

-
-
setScheduleScope("all")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A todos mis colaboradores

-

Sobrescribe masivamente los horarios de todo tu equipo.

-
- - {scheduleScope === "specific" && ( -
- - -
- )} -
-
- - -
- {[ - { id: 'mon-fri', label: 'Lunes a Viernes' }, - { id: 'mon-sat', label: 'Lunes a Sábado' }, - { id: 'mon-sun', label: 'Todos los días' } - ].map(mode => ( -
setWorkingDaysMode(mode.id)} - style={{ - padding: '15px', - borderRadius: '12px', - cursor: 'pointer', - background: workingDaysMode === mode.id ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${workingDaysMode === mode.id ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', - textAlign: 'center', - transition: 'all 0.2s ease' - }} - > - {mode.label} -
- ))} -
-
- - -
-
- - setOpenTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setCloseTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - { - if (hasBreak) { - handleNext(); - } else { - handleUpdateScheduleSubmit(); - } - }} - disableNext={hasBreak === null} - nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Horarios")} - > -
-
setHasBreak(true)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Sí, hay descanso -
-
setHasBreak(false)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - No, horario corrido -
-
-
- - -
-
- - setBreakStart(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setBreakEnd(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - ); - }; + const renderUpdateScheduleFlow = () => ( + + ); @@ -2711,20 +1156,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm if (!createdCompanyId || isCreating || !scheduleScope || !overrideDate) return; setIsCreating(true); try { - let targetEmployees: any[] = []; - - if (scheduleScope === "me") { - const me = allEmployees.find((e: any) => { - const eUserId = e.userId?._id || e.userId?.id || e.userId; - return String(eUserId) === String(SessionInfo.userId); - }); - if (me) targetEmployees.push(me); - } else if (scheduleScope === "specific") { - const specific = allEmployees.find(e => e.id === targetEmployeeId || e._id === targetEmployeeId || e.employeeId === targetEmployeeId); - if (specific) targetEmployees.push(specific); - } else if (scheduleScope === "all") { - targetEmployees = [...allEmployees]; - } + const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); if (targetEmployees.length === 0) { alert("No se encontró ningún colaborador seleccionado."); @@ -2732,20 +1164,10 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm return; } - let schedules: Array<{ from: string; to: string; disabled: boolean }> = []; - if (hasBreak) { - schedules = [ - { from: openTime, to: breakStart, disabled: false }, - { from: breakEnd, to: closeTime, disabled: false } - ]; - } else { - schedules = [ - { from: openTime, to: closeTime, disabled: false } - ]; - } + const schedules = buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd); for (const emp of targetEmployees) { - const empId = emp.id || emp._id || emp.employeeId; + const empId = getEmployeeId(emp); if (!empId) continue; await API.post("schedules-overrides/update", { @@ -2765,218 +1187,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderOverrideScheduleFlow = () => { - return ( - <> - { - if (scheduleScope === "specific" && !targetEmployeeId) { - alert("Por favor selecciona un colaborador."); - return; - } - handleNext(); - }} - disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} - > -
-
setScheduleScope("me")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

Solo a mí

-
-
setScheduleScope("specific")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A un colaborador en particular

-
-
setScheduleScope("all")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A todos mis colaboradores

-
- - {scheduleScope === "specific" && ( -
- -
- )} -
-
- - -
- setOverrideDate(e.target.value)} - style={{ - padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'sans-serif' - }} - /> -
-
- - -
-
- - setOpenTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setCloseTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - { - if (hasBreak) { - handleNext(); - } else { - handleOverrideScheduleSubmit(); - } - }} - disableNext={hasBreak === null} - nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Excepción")} - > -
-
setHasBreak(true)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Sí, hay descanso -
-
setHasBreak(false)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - No, de corrido -
-
-
- - -
-
- - setBreakStart(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setBreakEnd(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - ); - }; + const renderOverrideScheduleFlow = () => ( + + ); @@ -2984,20 +1219,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm if (!createdCompanyId || isCreating || !scheduleScope || !disableDuration) return; setIsCreating(true); try { - let targetEmployees: any[] = []; - - if (scheduleScope === "me") { - const me = allEmployees.find((e: any) => { - const eUserId = e.userId?._id || e.userId?.id || e.userId; - return String(eUserId) === String(SessionInfo.userId); - }); - if (me) targetEmployees.push(me); - } else if (scheduleScope === "specific") { - const specific = allEmployees.find(e => e.id === targetEmployeeId || e._id === targetEmployeeId || e.employeeId === targetEmployeeId); - if (specific) targetEmployees.push(specific); - } else if (scheduleScope === "all") { - targetEmployees = [...allEmployees]; - } + const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); if (targetEmployees.length === 0) { alert("No se encontró ningún colaborador seleccionado."); @@ -3034,7 +1256,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } const employeesPayload = targetEmployees.map(emp => ({ - employeeId: emp.id || emp._id || emp.employeeId + employeeId: getEmployeeId(emp) })); await API.post("schedules-disabled/disable", { @@ -3054,923 +1276,195 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderDisableScheduleFlow = () => { - return ( - <> - { - if (scheduleScope === "specific" && !targetEmployeeId) { - alert("Por favor selecciona un colaborador."); - return; - } - handleNext(); - }} - disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} - > -
-
setScheduleScope("me")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

Solo a mí

-
-
setScheduleScope("specific")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A un colaborador en particular

-
-
setScheduleScope("all")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A todos mis colaboradores

-
- - {scheduleScope === "specific" && ( -
- -
- )} -
-
+ const renderDisableScheduleFlow = () => ( + + ); - -
-
setDisableDuration("one-day")} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: disableDuration === "one-day" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${disableDuration === "one-day" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Solo por un día -
-
setDisableDuration("multi-day")} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: disableDuration === "multi-day" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${disableDuration === "multi-day" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Por varios días -
-
-
+ const { + handleSearchCollaboratorEmail, + handleCreateCollaborator, + resetInviteFlow + } = useNewCollaboratorHandlers({ + inviteEmail, + inviteCollaborator, + inviteRole, + createdCompanyId, + sessionUserId: SessionInfo.userId, + setInviteEmail, + setInviteCollaborator, + setInviteEmailError, + setInviteRole, + setIsSearchingEmail, + setIsCreating, + setIsUpgradeRequired, + setInviteFlowNotice, + setCurrentStepIndex, + handleNext + }); - {disableDuration === "one-day" && ( - <> - -
- setDisableStartDate(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '16px', fontFamily: 'sans-serif' - }} - /> -
-
- - { - if (isFullDayDisabled) { - handleDisableScheduleSubmit(); - } else if (disableStartTime && disableEndTime) { - handleDisableScheduleSubmit(); - } - }} - nextLabel={isCreating ? "Guardando..." : "Cerrar Agenda"} - disableNext={(!isFullDayDisabled && (!disableStartTime || !disableEndTime)) || isCreating} - > -
-
-
setIsFullDayDisabled(true)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: isFullDayDisabled === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${isFullDayDisabled === true ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Todo el día -
-
setIsFullDayDisabled(false)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: isFullDayDisabled === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${isFullDayDisabled === false ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Franja Específica -
-
- {!isFullDayDisabled && ( -
-
- - setDisableStartTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setDisableEndTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- )} -
-
- - )} - - {disableDuration === "multi-day" && ( - <> - -
-
- - setDisableStartDate(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '16px', fontFamily: 'sans-serif' - }} - /> -
-
- - setDisableStartTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - -
-
- - setDisableEndDate(e.target.value)} - min={disableStartDate} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '16px', fontFamily: 'sans-serif' - }} - /> -
-
- - setDisableEndTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - )} - - ); + const renderNewCollaboratorFlow = () => ( + + ); + const handleSetupIntroNext = () => { + if (setupMissingServices) setCurrentStepIndex(1); + else if (setupMissingSchedules) setCurrentStepIndex(2); }; - const handleSearchCollaboratorEmail = async () => { - if (!inviteEmail || !inviteEmail.includes("@")) return; - setIsSearchingEmail(true); - setInviteEmailError(""); - setInviteCollaborator(null); - try { - const data = await API.post("users/get-by-email", { email: inviteEmail.trim().toLowerCase() }); - if (data && (data.id || data._id)) { - if ((data.id || data._id) === SessionInfo.userId) { - setInviteEmailError("No puedes invitarte a ti mismo."); - setIsSearchingEmail(false); - return; - } - setInviteCollaborator(data); - handleNext(); // Go to role selection - } else { - setInviteEmailError("Usuario no registrado"); - } - } catch (error: any) { - setInviteEmailError("Usuario no registrado"); - } finally { - setIsSearchingEmail(false); + const handleSetupServicesNext = async () => { + if (setupSelectedServices.length === 0) { + alert("Debes seleccionar al menos un servicio"); + return; } - }; - - const handleCreateCollaborator = async () => { - if (!inviteCollaborator || !createdCompanyId) return; setIsCreating(true); - setInviteFlowNotice(""); try { - await API.post("employees/create", { - companyId: createdCompanyId, - userId: inviteCollaborator.id || inviteCollaborator._id, - roles: inviteRole === "admin" ? ["admin"] : [], - hostOk: true, - sessionUser: SessionInfo.userId - }); - handleNext(); - } catch (error: any) { - const errorMessage = error.desc || error.message || "Error al invitar al colaborador"; - const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - if (normalizedErrorMessage.includes("limite")) { - setIsUpgradeRequired(true); - } else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) { - setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió."); - setCurrentStepIndex(5); - } else if (normalizedErrorMessage.includes("ya pertenece")) { - setInviteFlowNotice("Este usuario ya pertenece a la compañía."); - setCurrentStepIndex(6); - } else { - setInviteFlowNotice(errorMessage); - setCurrentStepIndex(5); + for (const serviceId of setupSelectedServices) { + await API.post("employees/add-service", { + companyId: createdCompanyId, + employeeId: employeeId, + serviceId: serviceId, + sessionUser: SessionInfo.userId + }); } + if (setupMissingSchedules) { + setCurrentStepIndex(2); + } else { + setIsSuccess(true); + } + } catch (e) { + console.error(e); } finally { setIsCreating(false); } }; - const renderNewCollaboratorFlow = () => { - return ( - <> - - { - setInviteEmail(e.target.value); - setInviteEmailError(""); - }} - onKeyDown={(e) => { - if (inviteEmail.includes("@") && !isSearchingEmail) { - handleKeyDown(e, handleSearchCollaboratorEmail); - } - }} - autoFocus - /> - {inviteEmailError && ( -
- No encontramos este e-mail.
- Asegúrate de que la persona ya haya creado una cuenta en TurnosXpress con este correo. Pídele que se registre primero y vuelve a intentarlo. -
- )} -
- - - {inviteCollaborator && ( -
- {inviteCollaborator.avatar ? ( - Avatar - ) : ( - - )} -
{inviteCollaborator.fullName}
-
- )} -
-
setInviteRole("colaborador")} - style={{ padding: '15px', borderRadius: '12px', background: inviteRole === "colaborador" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', border: `1px solid ${inviteRole === "colaborador" ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Colaborador
-
Solo podrá ver y gestionar su propia agenda de turnos.
-
-
setInviteRole("admin")} - style={{ padding: '15px', borderRadius: '12px', background: inviteRole === "admin" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', border: `1px solid ${inviteRole === "admin" ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Administrador
-
Tendrá acceso total para modificar horarios, servicios y otros colaboradores de esta organización.
-
-
-
- - - {null} - - - -
-
{ - setInviteEmail(""); - setInviteCollaborator(null); - setInviteEmailError(""); - setInviteRole("colaborador"); - setIsUpgradeRequired(false); - setInviteFlowNotice(""); - setCurrentStepIndex(1); - }} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Invitar otro colaborador
-
Agrega otra persona a tu equipo.
-
-
{ - const query = createdCompanyId ? `&companyId=${createdCompanyId}` : ""; - router.push(`/admin/assistant?flow=update-schedule${query}`); - }} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Configurar mis horarios
-
Define tus horarios personales de atención.
-
-
router.push("/admin/assistant")} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Terminar
-
Volver a la home del asistente.
-
-
-
- - -
-
{ - setInviteEmail(""); - setInviteCollaborator(null); - setInviteEmailError(""); - setInviteRole("colaborador"); - setIsUpgradeRequired(false); - setInviteFlowNotice(""); - setCurrentStepIndex(1); - }} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Invitar otro colaborador
-
Busca otra persona para sumar al equipo.
-
-
router.push("/admin/assistant")} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Volver al asistente
-
Regresar a la home del asistente.
-
-
-
- - -
-
{ - setInviteEmail(""); - setInviteCollaborator(null); - setInviteEmailError(""); - setInviteRole("colaborador"); - setIsUpgradeRequired(false); - setInviteFlowNotice(""); - setCurrentStepIndex(1); - }} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Invitar otro colaborador
-
Busca otra persona para sumar al equipo.
-
-
router.push("/admin/assistant")} - style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }} - > -
Volver al asistente
-
Regresar a la home del asistente.
-
-
-
- - window.location.href = "/landing/pricing"} - nextLabel="Ver Planes y Mejorar" - > -
-
🚀
-

Beneficios de expandirte

-
    -
  • Agendas ilimitadas para todo tu equipo.
  • -
  • Aumento exponencial de tus reservas diarias.
  • -
  • Control total sobre los permisos de cada integrante.
  • -
-
-
- - ); - }; - const renderSetupCollaboratorFlow = () => { - if (currentStepIndex === 0) { - return ( - { - if (setupMissingServices) setCurrentStepIndex(1); - else if (setupMissingSchedules) setCurrentStepIndex(2); - }} - nextLabel="Comenzar Configuración" - > -
-
⚠️
-

Hemos detectado que a este colaborador le falta:

-
- {setupMissingServices && ( -
- ✂️ Servicios asignados -
- )} - {setupMissingSchedules && ( -
- 🕒 Horarios de atención -
- )} -
-

Te guiaremos paso a paso para configurarlo rápidamente.

-
-
- ); + const handleSetupSchedulesNext = async (canProceed: () => boolean) => { + if (!canProceed()) { + alert("Por favor completa la configuración de días y horarios"); + return; } + setIsCreating(true); + try { + const mappedDays = getWorkingDays(workingDaysMode, customDays); + const days = mappedDays.length > 0 ? mappedDays : [0, 1, 2, 3, 4, 5, 6]; - if (currentStepIndex === 1) { - return ( - { - if (setupSelectedServices.length === 0) { - alert("Debes seleccionar al menos un servicio"); - return; - } - setIsCreating(true); - try { - for (const serviceId of setupSelectedServices) { - await API.post("employees/add-service", { - companyId: createdCompanyId, - employeeId: employeeId, - serviceId: serviceId, - sessionUser: SessionInfo.userId - }); - } - if (setupMissingSchedules) { - setCurrentStepIndex(2); - } else { - setIsSuccess(true); - } - } catch (e) { - console.error(e); - } finally { - setIsCreating(false); - } - }} - nextLabel="Guardar y Continuar" - disableNext={isCreating} - > -
- {setupAvailableServices.map(service => ( -
{ - const id = service.id || service._id; - setSetupSelectedServices(prev => - prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] - ); - }} - style={{ - padding: "15px", - borderRadius: "10px", - background: setupSelectedServices.includes(service.id || service._id) ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.05)", - border: `1px solid ${setupSelectedServices.includes(service.id || service._id) ? "#fff" : "rgba(255,255,255,0.1)"}`, - cursor: "pointer", - display: "flex", - justifyContent: "space-between", - alignItems: "center" - }} - > -
-
{service.name}
-
{service.duration} min - ${service.price}
-
- {setupSelectedServices.includes(service.id || service._id) && ( -
- )} -
- ))} - {setupAvailableServices.length === 0 && ( -
No hay servicios creados en la organización.
- )} -
-
- ); - } + for (const day of days) { + let dayOpenTime = openTime; + let dayCloseTime = closeTime; + let dayHasBreak = hasBreak; + let dayBreakStart = breakStart; + let dayBreakEnd = breakEnd; - if (currentStepIndex === 2) { - const DAY_LABELS = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; - const getDayLabel = (day: number) => DAY_LABELS[day]; - - const canProceed = () => { - if (!workingDaysMode) return false; - if (workingDaysMode === "custom") { - if (customDays.length === 0) return false; - if (unifiedCustomSchedule === null) return false; + if (workingDaysMode === "custom" && unifiedCustomSchedule === false) { + const customSettings = customSchedulesMap[day]; + if (customSettings) { + dayOpenTime = customSettings.openTime; + dayCloseTime = customSettings.closeTime; + dayHasBreak = customSettings.hasBreak; + dayBreakStart = customSettings.breakStart; + dayBreakEnd = customSettings.breakEnd; + } else { + dayOpenTime = "09:00"; + dayCloseTime = "18:00"; + dayHasBreak = false; + dayBreakStart = "13:00"; + dayBreakEnd = "14:00"; + } } - return true; - }; - return ( - { - if (!canProceed()) { - alert("Por favor completa la configuración de días y horarios"); - return; - } - setIsCreating(true); - try { - const days = workingDaysMode === "mon-fri" ? [1,2,3,4,5] : - workingDaysMode === "mon-sat" ? [1,2,3,4,5,6] : - workingDaysMode === "custom" ? customDays : - [0,1,2,3,4,5,6]; // mon-sun + const schedules = buildScheduleBlocks(dayOpenTime, dayCloseTime, dayHasBreak, dayBreakStart, dayBreakEnd); - for (const day of days) { - let dayOpenTime = openTime; - let dayCloseTime = closeTime; - let dayHasBreak = hasBreak; - let dayBreakStart = breakStart; - let dayBreakEnd = breakEnd; - - if (workingDaysMode === "custom" && unifiedCustomSchedule === false) { - const customSettings = customSchedulesMap[day]; - if (customSettings) { - dayOpenTime = customSettings.openTime; - dayCloseTime = customSettings.closeTime; - dayHasBreak = customSettings.hasBreak; - dayBreakStart = customSettings.breakStart; - dayBreakEnd = customSettings.breakEnd; - } else { - // Defaults if missing - dayOpenTime = "09:00"; - dayCloseTime = "18:00"; - dayHasBreak = false; - dayBreakStart = "13:00"; - dayBreakEnd = "14:00"; - } - } - - const schedules = dayHasBreak ? [ - { from: dayOpenTime, to: dayBreakStart, disabled: false }, - { from: dayBreakEnd, to: dayCloseTime, disabled: false } - ] : [ - { from: dayOpenTime, to: dayCloseTime, disabled: false } - ]; - - await API.post("schedules/create", { - companyId: createdCompanyId, - employeeId: employeeId, - weekDay: day, - schedules: schedules, - sessionUser: SessionInfo.userId - }); - } - setIsSuccess(true); - } catch (e) { - console.error(e); - alert("Ocurrió un error al guardar los horarios"); - } finally { - setIsCreating(false); - } - }} - nextLabel="Guardar Horarios" - disableNext={isCreating || !canProceed()} - > -
-
setWorkingDaysMode("mon-fri")} - > -

Lunes a Viernes

-

Horario comercial estándar

-
-
setWorkingDaysMode("mon-sat")} - > -

Lunes a Sábado

-

Jornada extendida

-
-
setWorkingDaysMode("mon-sun")} - > -

Todos los días

-

Lunes a Domingo

-
-
setWorkingDaysMode("custom")} - > -

Otros / Personalizado

-

Selecciona días específicos y horarios variables

-
-
- - {workingDaysMode === "custom" && ( -
-

Selecciona los días:

-
- {[1,2,3,4,5,6,0].map(day => ( -
setCustomDays(prev => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort())} - style={{ - padding: "8px 16px", - borderRadius: "20px", - cursor: "pointer", - background: customDays.includes(day) ? "var(--wine-red)" : "rgba(255,255,255,0.1)", - color: "white", - fontWeight: customDays.includes(day) ? "bold" : "normal", - transition: "all 0.2s", - border: `1px solid ${customDays.includes(day) ? "var(--wine-red)" : "transparent"}` - }} - > - {getDayLabel(day)} -
- ))} -
- - {customDays.length > 0 && ( -
-

¿Utiliza el mismo horario para todos estos días?

-
- - -
-
- )} -
- )} - - {((workingDaysMode && workingDaysMode !== "custom") || (workingDaysMode === "custom" && unifiedCustomSchedule === true && customDays.length > 0)) && ( -
-

Horario de apertura y cierre

-
- setOpenTime(e.target.value)} - /> - setCloseTime(e.target.value)} - /> -
- -
- setHasBreak(e.target.checked)} - style={{ width: "18px", height: "18px" }} - /> - -
- - {hasBreak && ( -
- setBreakStart(e.target.value)} - /> - setBreakEnd(e.target.value)} - /> -
- )} -
- )} - - {workingDaysMode === "custom" && unifiedCustomSchedule === false && customDays.length > 0 && ( -
- {customDays.map(day => { - const ds = customSchedulesMap[day] || { openTime: "09:00", closeTime: "18:00", hasBreak: false, breakStart: "13:00", breakEnd: "14:00" }; - const updateDS = (updates: any) => { - setCustomSchedulesMap(prev => ({...prev, [day]: { ...ds, ...updates } })); - }; - - return ( -
-

- Configuración para {getDayLabel(day)} -

-
- updateDS({ openTime: e.target.value })} /> - updateDS({ closeTime: e.target.value })} /> -
-
- updateDS({ hasBreak: e.target.checked })} style={{ width: "18px", height: "18px" }} /> - -
- {ds.hasBreak && ( -
- updateDS({ breakStart: e.target.value })} /> - updateDS({ breakEnd: e.target.value })} /> -
- )} -
- ); - })} -
- )} -
- ); + await API.post("schedules/create", { + companyId: createdCompanyId, + employeeId: employeeId, + weekDay: day, + schedules: schedules, + sessionUser: SessionInfo.userId + }); + } + setIsSuccess(true); + } catch (e) { + console.error(e); + alert("Ocurrió un error al guardar los horarios"); + } finally { + setIsCreating(false); } - - return null; }; + + const renderSetupCollaboratorFlow = () => ( + + ); const isSelectedOrganizationPaidPlan = () => { if (!createdCompanyId) return false; return (SessionInfo.organizationSubscriptions?.[createdCompanyId]?.plan?.price || 0) > 0; @@ -4016,84 +1510,37 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderVisibilityCheckbox = (label: string, description: string, checked: boolean, onChange: (checked: boolean) => void) => ( - + const renderPublicVisibilityFlow = () => ( + ); - const renderPublicVisibilityFlow = () => { - if (!isSelectedOrganizationPaidPlan()) { - return ( - router.push(createdCompanyId ? `/landing/upgrade-plan?org=${createdCompanyId}` : "/landing/pricing")} - nextLabel="Ver planes" - > -
- Estás configurando {selectedOrganization?.name ? {selectedOrganization.name} : "esta organización"}. Para cambiar qué opiniones, puntuaciones y profesionales se muestran públicamente, el dueño de esta organización necesita un plan pago. -
-
- ); - } - - return ( - -
-
-

Organización

- {renderVisibilityCheckbox("Mostrar puntuación pública", "Permite que los clientes vean la puntuación promedio de la organización.", orgShowPublicScores, setOrgShowPublicScores)} - {renderVisibilityCheckbox("Mostrar opiniones públicas", "Permite que los clientes vean las opiniones recibidas por la organización.", orgShowPublicOpinions, setOrgShowPublicOpinions)} - {renderVisibilityCheckbox("Mostrar profesionales públicos", "Permite que los clientes vean la lista de profesionales de la organización.", orgShowPublicProfessionals, setOrgShowPublicProfessionals)} -
- -
-

Servicios

- {renderVisibilityCheckbox("Mostrar puntuación pública de servicios", "Aplica este valor a todos los servicios de la organización.", servicesShowPublicScores, setServicesShowPublicScores)} - {renderVisibilityCheckbox("Mostrar opiniones públicas de servicios", "Aplica este valor a todos los servicios de la organización.", servicesShowPublicOpinions, setServicesShowPublicOpinions)} -
-
-
- ); - }; - const getSelectedOrganizationSlug = () => { - return selectedOrganization?.slug || getSlug(selectedOrganization?.name || orgName || ""); + return buildSelectedOrganizationSlug(selectedOrganization, orgName); }; const getPublicLink = () => { - const slug = getSelectedOrganizationSlug(); - const publicBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"; - return slug ? `${publicBaseUrl.replace(/\/$/, "")}/${slug}` : ""; + return buildPublicLink(selectedOrganization, orgName); }; const getPublicLinkQrUrl = () => { - const publicLink = getPublicLink(); - return publicLink ? `https://api.qrserver.com/v1/create-qr-code/?size=260x260&format=png&data=${encodeURIComponent(publicLink)}` : ""; + return buildPublicLinkQrUrl(selectedOrganization, orgName); }; const handleCopyPublicLink = async () => { @@ -4110,794 +1557,119 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; - const renderPublicLinkFlow = (options?: { onboardingCompletion?: boolean }) => { - const isOnboardingCompletion = options?.onboardingCompletion === true; - const publicLink = getPublicLink(); - const publicLinkQrUrl = getPublicLinkQrUrl(); - const orgSlug = getSelectedOrganizationSlug(); - - return ( - { - if (isOnboardingCompletion) { - setShowOnboardingPublicLink(false); - setIsSuccess(true); - return; - } - - router.push("/admin/assistant"); - }} - nextLabel={isOnboardingCompletion ? "Finalizar" : "Volver al asistente"} - topAccessory={} - > -
-
- Organización: {selectedOrganization?.name || orgName || "Sin organización seleccionada"} - {orgSlug && ( -
- Slug público: {orgSlug} -
- )} -
- - {publicLink ? ( - <> -
- Link público - - {publicLink} - -
- -
- - - - Descargar QR - -
- -
-
- {`Código -
- - Este QR apunta al mismo link público. Si cambiás el nombre o slug de la organización, generá y compartí el QR actualizado. - -
- - ) : ( -
- No encontramos un slug para esta organización. Revisá el nombre de la organización en la configuración del perfil y volvé a intentar. -
- )} -
-
- ); - }; - - const NOTIF_CHANNELS: { key: NotificationChannel; label: string; description: string }[] = [ - { key: "whatsapp", label: "WhatsApp", description: "Mensajes automáticos por WhatsApp." }, - { key: "email", label: "Email", description: "Correos electrónicos al cliente." }, - { key: "system", label: "Sistema", description: "Notificaciones dentro de la plataforma." } - ]; - - const NOTIF_REMINDER_PRESETS: { minutes: number; label: string }[] = [ - { minutes: 60, label: "1 hora antes" }, - { minutes: 180, label: "3 horas antes" }, - { minutes: 360, label: "6 horas antes" }, - { minutes: 720, label: "12 horas antes" }, - { minutes: 1440, label: "1 día antes" }, - { minutes: 2880, label: "2 días antes" } - ]; - - const toggleNotifChannel = (channel: NotificationChannel) => { - setNotifChannels(prev => - prev.includes(channel) ? prev.filter(c => c !== channel) : [...prev, channel] - ); - }; - - const toggleNotifReminder = (minutes: number) => { - const exists = notifReminderRules.some(r => r.offset === minutes); - if (exists) { - setNotifReminderRules(prev => prev.filter(r => r.offset !== minutes)); - } else { - setNotifReminderRules(prev => [...prev, { offset: minutes, enabled: true }]); - } - }; - - const handleSelectNotifScope = (scope: "org" | "client") => { - setNotificationsScope(scope); - setNotifChannels([]); - setNotifQuietHoursActive(false); - setNotifQuietHoursFrom("22:00"); - setNotifQuietHoursTo("08:00"); - setNotifReminderRules([]); - setNotifSelectedClientId(null); - }; - - const dayjsFromHHmm = (hhmm: string) => { - const [h, m] = (hhmm || "00:00").split(":").map(Number); - return dayjs().hour(h || 0).minute(m || 0).second(0).millisecond(0); - }; - - const handleSaveNotifications = async () => { - if (!createdCompanyId || isCreating) return; - if (notificationsScope === "client" && !notifSelectedClientId) return; - setIsCreating(true); - try { - if (notificationsScope === "org") { - const quietHours = notifQuietHoursActive - ? { from: notifQuietHoursFrom, to: notifQuietHoursTo } - : null; - await API.post("notifications/policy/save", { - companyId: createdCompanyId, - sessionUser: SessionInfo.userId, - defaultChannels: notifChannels, - timezone: "America/Argentina/Buenos_Aires", - quietHours: quietHours || undefined, - reminderRules: notifReminderRules - }); - } else if (notificationsScope === "client") { - const preferredChannels = notifChannels as NotificationChannel[]; - const allChannels: NotificationChannel[] = ["whatsapp", "email", "system"]; - const mutedChannels = allChannels.filter(c => !preferredChannels.includes(c)); - await API.post("notifications/override/save", { - clientId: notifSelectedClientId, - companyId: createdCompanyId, - sessionUser: SessionInfo.userId, - preferredChannels, - mutedChannels - }); - } - handleActionSuccess(); - } catch (error: any) { - console.error("Error guardando notificaciones:", error); - alert(error?.message || "Hubo un error al guardar las notificaciones."); - } finally { - setIsCreating(false); - } - }; - - const renderNotificationsChannelToggle = (channel: NotificationChannel) => ( -
-
- {NOTIF_CHANNELS.find(c => c.key === channel)?.label} - - {NOTIF_CHANNELS.find(c => c.key === channel)?.description} - -
- toggleNotifChannel(channel)} - disabled={notifLoading} - /> -
+ const renderPublicLinkFlow = (options?: { onboardingCompletion?: boolean }) => ( + ); - const renderNotificationsFlow = () => { - return ( - <> - { - if (!notificationsScope) return; - handleNext(); - }} - disableNext={!notificationsScope} - nextLabel="Siguiente" - > -
-
handleSelectNotifScope("org")} - style={{ - padding: "18px", borderRadius: "12px", cursor: "pointer", - background: notificationsScope === "org" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `2px solid ${notificationsScope === "org" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white", textAlign: "center", transition: "all 0.2s ease" - }} - > -

- A la organización en general -

-

- Configura canales, recordatorios y horario silencioso para todos los clientes. -

-
-
handleSelectNotifScope("client")} - style={{ - padding: "18px", borderRadius: "12px", cursor: "pointer", - background: notificationsScope === "client" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `2px solid ${notificationsScope === "client" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white", textAlign: "center", transition: "all 0.2s ease" - }} - > -

- A un cliente específico -

-

- Elige un cliente y define qué canales de notificación recibe. -

-
-
-
- Estos ajustes se sincronizan con la página de notificaciones de tu organización, así que después podés seguir ajustándolos desde ahí. -
-
+ const { + toggleNotifChannel, + toggleNotifReminder, + handleSelectNotifScope, + handleSaveNotifications + } = useNotificationsHandlers({ + createdCompanyId, + sessionUserId: SessionInfo.userId, + isCreating, + notificationsScope, + notifChannels, + notifQuietHoursActive, + notifQuietHoursFrom, + notifQuietHoursTo, + notifReminderRules, + notifSelectedClientId, + setNotificationsScope, + setNotifChannels, + setNotifQuietHoursActive, + setNotifQuietHoursFrom, + setNotifQuietHoursTo, + setNotifReminderRules, + setNotifSelectedClientId, + setIsCreating, + handleActionSuccess + }); - {notificationsScope === "org" && ( - <> - -
- {NOTIF_CHANNELS.map(c => renderNotificationsChannelToggle(c.key))} -
-
+ const renderNotificationsFlow = () => ( + + ); - -
- {NOTIF_REMINDER_PRESETS.map(preset => { - const isActive = notifReminderRules.some(r => r.offset === preset.minutes); - return ( -
toggleNotifReminder(preset.minutes)} - style={{ - padding: "10px 18px", borderRadius: "999px", cursor: "pointer", - background: isActive ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `1px solid ${isActive ? "var(--wine-red)" : "rgba(255,255,255,0.15)"}`, - color: "white", - fontWeight: isActive ? "bold" : "normal", - transition: "all 0.2s ease", - fontSize: "14px" - }} - > - {preset.label} -
- ); - })} -
-
- {notifReminderRules.length === 0 - ? "No hay recordatorios activos todavía." - : `Recordatorios activos: ${notifReminderRules.length}.`} -
-
- - -
-
- Activar horario silencioso - setNotifQuietHoursActive(checked)} - disabled={notifLoading} - /> -
- {notifQuietHoursActive && ( -
-
- setNotifQuietHoursFrom(v.format("HH:mm"))} - /> -
-
- setNotifQuietHoursTo(v.format("HH:mm"))} - /> -
-
- )} -
-
- - -
-
-
Canales activos
-
- {notifChannels.length > 0 - ? notifChannels.map(c => NOTIF_CHANNELS.find(nc => nc.key === c)?.label).join(", ") - : "Ninguno"} -
-
-
-
Recordatorios
-
- {notifReminderRules.length > 0 - ? notifReminderRules.map(r => NOTIF_REMINDER_PRESETS.find(p => p.minutes === r.offset)?.label || `${r.offset} min`).join(", ") - : "Ninguno"} -
-
-
-
Horario silencioso
-
- {notifQuietHoursActive ? `De ${notifQuietHoursFrom} a ${notifQuietHoursTo}` : "Desactivado"} -
-
-
-
- - )} - - {notificationsScope === "client" && ( - <> - { - if (!notifSelectedClientId) return; - handleNext(); - }} - disableNext={!notifSelectedClientId || notifLoading} - nextLabel={notifLoading ? "Cargando..." : "Siguiente"} - > -
- {notifClients.length === 0 ? ( -
- No hay clientes para esta organización todavía. Cuando alguien reserve un turno, aparecerá acá. -
- ) : notifSelectedClientId ? (() => { - const sc = notifClients.find(c => c.id === notifSelectedClientId); - const sub = sc ? ((sc.email && sc.email.trim() !== "") ? sc.email : [sc.phoneCountryCode, sc.phoneAreaCode, sc.phoneNumber].filter(p => p && String(p).trim() !== "").map(p => String(p).trim()).join(" ")).replace(/^(?!\+)/, "+") : ""; - return ( - - ); - })() : ( - - )} -
-
- - -
- {NOTIF_CHANNELS.map(c => renderNotificationsChannelToggle(c.key))} -
-
- - -
-
-
Cliente
-
- {notifClients.find(c => c.id === notifSelectedClientId)?.fullName || "—"} -
-
-
-
Canales activos
-
- {notifChannels.length > 0 - ? notifChannels.map(c => NOTIF_CHANNELS.find(nc => nc.key === c)?.label).join(", ") - : "Ninguno"} -
-
-
-
- - { - setNotifChannels([]); - setNotifSelectedClientId(id); - setNotifClientPickerOpen(false); - }} - onClose={() => setNotifClientPickerOpen(false)} - /> - - )} - - ); - }; - - const handleExtendReservationPeriods = async () => { - if (!createdCompanyId || !reservationPeriodScope || !reservationPeriodDuration || isCreating) return; - if (reservationPeriodEmployeesLoading || reservationPeriodEmployeesError || allEmployees.length === 0) return; - if (reservationPeriodScope === "employee" && !targetEmployeeId) return; - - setIsCreating(true); - try { - await API.post("schedules-enabled/reservation-periods/extend", { - companyId: createdCompanyId, - scope: reservationPeriodScope, - employeeId: reservationPeriodScope === "employee" ? targetEmployeeId : undefined, - duration: reservationPeriodDuration, - sessionUser: SessionInfo.userId - }); - setIsSuccess(true); - } catch (error: any) { - console.error("Error extendiendo período de reservas:", error); - alert(error?.message || "Hubo un error al extender el período de reservas."); - } finally { - setIsCreating(false); - } - }; - - const renderReservationPeriodFlow = () => { - if (reservationPeriodEmployeesLoading) { - return ( - -
- - ); - } - - if (reservationPeriodEmployeesError || allEmployees.length === 0) { - return ( - -
- - ); - } - - const needsEmployeeSelection = allEmployees.length > 1 && reservationPeriodScope === "employee"; - const selectedEmployee = allEmployees.find((employee: any) => String(employee.id || employee._id || employee.employeeId) === String(targetEmployeeId)); - const reservationPeriodEmployees = reservationPeriodSummary?.employees || []; - const getReservationPeriodEmployeeSummary = (employee: any) => { - const employeeKey = employee.id || employee._id || employee.employeeId; - return reservationPeriodEmployees.find(summary => String(summary.employeeId) === String(employeeKey)); - }; - const getReservationPeriodStatus = (employee: any) => { - const summary = getReservationPeriodEmployeeSummary(employee); - const daysRemaining = summary?.daysRemaining; - const safeDaysRemaining = typeof daysRemaining === "number" ? Math.max(daysRemaining, 0) : 0; - const progress = Math.min(Math.round((safeDaysRemaining / 30) * 100), 100); - const color = !summary?.isOpen || safeDaysRemaining === 0 - ? "#ef4444" - : safeDaysRemaining <= 7 - ? "#f59e0b" - : "#22c55e"; - - let label = "Sin período abierto"; - if (summary?.isOpen && typeof daysRemaining === "number") { - if (daysRemaining < 0) { - label = "Vencido"; - } else if (daysRemaining === 1) { - label = "1 día restante"; - } else { - label = `${daysRemaining} días restantes`; - } - } else if (typeof daysRemaining === "number" && daysRemaining < 0) { - label = "Vencido"; - } - - return { - label, - color, - progress, - openUntilLabel: summary?.currentOpenUntil ? `Abierto hasta ${dayjs(summary.currentOpenUntil).format("D [de] MMMM")}` : null - }; - }; - - return ( - <> - {allEmployees.length > 1 && ( - -
-
{ - setReservationPeriodScope("all"); - setTargetEmployeeId(null); - }} - style={{ - padding: "18px", borderRadius: "12px", cursor: "pointer", - background: reservationPeriodScope === "all" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `2px solid ${reservationPeriodScope === "all" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white", textAlign: "center", transition: "all 0.2s ease" - }} - > -

Todos los colaboradores

-

Extiende el período de reservas para todo el equipo.

-
-
setReservationPeriodScope("employee")} - style={{ - padding: "18px", borderRadius: "12px", cursor: "pointer", - background: reservationPeriodScope === "employee" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `2px solid ${reservationPeriodScope === "employee" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white", textAlign: "center", transition: "all 0.2s ease" - }} - > -

Un colaborador específico

-

Elige una persona del equipo.

-
- {needsEmployeeSelection && ( -
- {allEmployees.map((employee: any) => { - const employeeKey = employee.id || employee._id || employee.employeeId; - const isSelected = String(targetEmployeeId) === String(employeeKey); - const periodStatus = getReservationPeriodStatus(employee); - - return ( -
setTargetEmployeeId(employeeKey)} - style={{ - padding: "12px 14px", borderRadius: "10px", cursor: "pointer", - background: isSelected ? "rgba(255,42,127,0.18)" : "rgba(255,255,255,0.04)", - border: `1px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white" - }} - > -
-
- - {employee.fullName || `${employee.firstName || ""} ${employee.lastName || ""}`.trim() || employee.email || "Colaborador"} - - - {periodStatus.label} - -
-
-
0 ? "8px" : 0, - height: "100%", - borderRadius: "999px", - background: periodStatus.color, - transition: "width 0.2s ease" - }} - /> -
- {periodStatus.openUntilLabel && ( - - {periodStatus.openUntilLabel} - - )} -
-
- ); - })} -
- )} -
- - )} - - 1 ? 1 : 0)} - title="Elegí cuánto extender" - description="Abriremos un nuevo bloque continuo desde el último día actualmente disponible." - onNext={handleNext} - disableNext={!reservationPeriodDuration} - nextLabel="Siguiente" - > -
- {reservationPeriodOptions.map(option => { - const isSelected = reservationPeriodDuration?.unit === option.unit && reservationPeriodDuration.value === option.value; - - return ( -
setReservationPeriodDuration({ unit: option.unit, value: option.value })} - style={{ - padding: "12px 24px", borderRadius: "12px", cursor: "pointer", - background: isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.05)", - border: `2px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, - color: "white", fontWeight: isSelected ? "bold" : "normal", - transition: "all 0.2s ease", fontSize: "16px" - }} - > - {option.label} -
- ); - })} -
-
- - 1 ? 2 : 1)} - title="Confirmar extensión" - description="Revisá la selección antes de abrir el nuevo período de reservas." - onNext={handleExtendReservationPeriods} - disableNext={isCreating || reservationPeriodEmployeesLoading || Boolean(reservationPeriodEmployeesError) || allEmployees.length === 0 || !reservationPeriodScope || !reservationPeriodDuration || (reservationPeriodScope === "employee" && !targetEmployeeId)} - nextLabel={isCreating ? "Extendiendo..." : "Extender período"} - > -
-
-
Organización
-
{selectedOrganization?.name || orgName}
-
-
-
Aplicar a
-
{reservationPeriodScope === "all" ? "Todos los colaboradores" : (selectedEmployee?.fullName || selectedEmployee?.email || "Colaborador seleccionado")}
-
-
-
Duración
-
{reservationPeriodOptions.find(option => option.unit === reservationPeriodDuration?.unit && option.value === reservationPeriodDuration.value)?.label}
-
- {reservationPeriodSummary?.worstDaysRemaining !== null && reservationPeriodSummary?.worstDaysRemaining !== undefined && ( -
- Menor disponibilidad actual: {reservationPeriodSummary.worstDaysRemaining} días restantes. -
- )} -
-
- - ); - }; + const { handleExtendReservationPeriods } = useReservationPeriodHandlers({ + createdCompanyId, + sessionUserId: SessionInfo.userId, + isCreating, + reservationPeriodScope, + reservationPeriodDuration, + reservationPeriodEmployeesLoading, + reservationPeriodEmployeesError, + allEmployees, + targetEmployeeId, + setIsCreating, + setIsSuccess + }); + const renderReservationPeriodFlow = () => ( + + ); const renderFlow = () => { if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") { - return ( - {}} - disableNext={true} - nextLabel="" - > -
- {allOrganizations.map(org => ( -
loadOrganizationContext(org)} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: 'rgba(255,255,255,0.05)', - border: '2px solid transparent', - color: 'white', transition: 'all 0.2s ease', - display: 'flex', flexDirection: 'column', alignItems: 'center' - }} - onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--wine-red)'} - onMouseLeave={(e) => e.currentTarget.style.borderColor = 'transparent'} - > -

{org.name}

-

{org.street} {org.streetNumber}, {org.city}

-
- ))} -
-
- ); + return ; } if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow(); @@ -4931,93 +1703,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } if (isSuccess) { - const getSuccessMessage = () => { - if (action === "new-service") return "Has agregado tu nuevo servicio exitosamente."; - if (action === "edit-schedule") return "Tus horarios se han actualizado exitosamente."; - if (action === "edit-location") return "La ubicación de tu negocio ha sido actualizada."; - if (action === "edit-contact") return "Tus datos de contacto han sido actualizados."; - if (action === "edit-appearance") return "La apariencia de tu negocio ha sido actualizada."; - if (flowId === "override-schedule" || flowId === "disable-schedule") return "El horario especial ha sido guardado exitosamente."; - 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 (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente."; - if (isStandaloneAction) 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."; - }; - - const successButtonStyle: React.CSSProperties = { - padding: '16px 40px', fontSize: '18px', fontWeight: 'bold', - background: 'white', color: 'var(--wine-red)', border: 'none', - borderRadius: '30px', cursor: 'pointer', - boxShadow: '0 4px 15px rgba(0,0,0,0.3)', - transition: 'transform 0.2s, box-shadow 0.2s' - }; - - const handleSuccessButtonMouseOver = (e: React.MouseEvent) => { - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)'; - }; - - const handleSuccessButtonMouseOut = (e: React.MouseEvent) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)'; - }; - return ( -
-
- 🎉 -
- -

- ¡Felicitaciones! -

- -

- {getSuccessMessage()} -

- - - - -
+ ); } diff --git a/txclient/src/app/admin/assistant/components/StepEngine/components/OrganizationPicker.tsx b/txclient/src/app/admin/assistant/components/StepEngine/components/OrganizationPicker.tsx new file mode 100644 index 0000000..62562db --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/components/OrganizationPicker.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function OrganizationPicker({ allOrganizations, loadOrganizationContext }: Props) { + return ( + {}} + disableNext={true} + nextLabel="" + > +
+ {allOrganizations.map((org: any) => ( +
loadOrganizationContext(org)} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: 'rgba(255,255,255,0.05)', + border: '2px solid transparent', + color: 'white', transition: 'all 0.2s ease', + display: 'flex', flexDirection: 'column', alignItems: 'center' + }} + onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--wine-red)'} + onMouseLeave={(e) => e.currentTarget.style.borderColor = 'transparent'} + > +

{org.name}

+

{org.street} {org.streetNumber}, {org.city}

+
+ ))} +
+
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx b/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx new file mode 100644 index 0000000..03b5118 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx @@ -0,0 +1,79 @@ +import React from "react"; + +type Props = any; + +export default function SuccessScreen(props: Props) { + const { action, flowId, isStandaloneAction, router } = props; + + const getSuccessMessage = () => { + if (action === "new-service") return "Has agregado tu nuevo servicio exitosamente."; + if (action === "edit-schedule") return "Tus horarios se han actualizado exitosamente."; + if (action === "edit-location") return "La ubicación de tu negocio ha sido actualizada."; + if (action === "edit-contact") return "Tus datos de contacto han sido actualizados."; + if (action === "edit-appearance") return "La apariencia de tu negocio ha sido actualizada."; + if (flowId === "override-schedule" || flowId === "disable-schedule") return "El horario especial ha sido guardado exitosamente."; + 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 (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente."; + if (isStandaloneAction) 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."; + }; + + const successButtonStyle: React.CSSProperties = { + padding: '16px 40px', fontSize: '18px', fontWeight: 'bold', + background: 'white', color: 'var(--wine-red)', border: 'none', + borderRadius: '30px', cursor: 'pointer', + boxShadow: '0 4px 15px rgba(0,0,0,0.3)', + transition: 'transform 0.2s, box-shadow 0.2s' + }; + + return ( +
+
+ 🎉 +
+

+ ¡Felicitaciones! +

+

+ {getSuccessMessage()} +

+ + +
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/constants/notifications.ts b/txclient/src/app/admin/assistant/components/StepEngine/constants/notifications.ts new file mode 100644 index 0000000..8ba9b68 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/constants/notifications.ts @@ -0,0 +1,18 @@ +import { NotificationChannel } from "@models/NotificationPreferences.model"; + +export const NOTIF_CHANNELS: { key: NotificationChannel; label: string; description: string }[] = [ + { key: "whatsapp", label: "WhatsApp", description: "Mensajes automáticos por WhatsApp." }, + { key: "email", label: "Email", description: "Correos electrónicos al cliente." }, + { key: "system", label: "Sistema", description: "Notificaciones dentro de la plataforma." } +]; + +export const NOTIF_REMINDER_PRESETS: { minutes: number; label: string }[] = [ + { minutes: 60, label: "1 hora antes" }, + { minutes: 180, label: "3 horas antes" }, + { minutes: 360, label: "6 horas antes" }, + { minutes: 720, label: "12 horas antes" }, + { minutes: 1440, label: "1 día antes" }, + { minutes: 2880, label: "2 días antes" } +]; + +export const ALL_NOTIF_CHANNELS: NotificationChannel[] = ["whatsapp", "email", "system"]; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/constants/reservationPeriods.ts b/txclient/src/app/admin/assistant/components/StepEngine/constants/reservationPeriods.ts new file mode 100644 index 0000000..0efcc00 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/constants/reservationPeriods.ts @@ -0,0 +1,9 @@ +import { ReservationPeriodDuration } from "@models/SchedulesEnabled.type"; + +export const reservationPeriodOptions: Array = [ + { unit: "week", value: 1, label: "1 semana" }, + { unit: "week", value: 2, label: "2 semanas" }, + { unit: "week", value: 3, label: "3 semanas" }, + { unit: "month", value: 1, label: "1 mes" }, + { unit: "month", value: 2, label: "2 meses" }, +]; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/DisableScheduleFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/DisableScheduleFlow.tsx new file mode 100644 index 0000000..31e7f0d --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/DisableScheduleFlow.tsx @@ -0,0 +1,292 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function DisableScheduleFlow(props: Props) { + const { currentStepIndex, scheduleScope, setScheduleScope, targetEmployeeId, setTargetEmployeeId, allEmployees, handleNext, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleUpdateScheduleSubmit, handleOverrideScheduleSubmit, disableDuration, setDisableDuration, disableStartDate, setDisableStartDate, disableEndDate, setDisableEndDate, isFullDayDisabled, setIsFullDayDisabled, disableStartTime, setDisableStartTime, disableEndTime, setDisableEndTime, handleDisableScheduleSubmit } = props; + return ( + <> + { + if (scheduleScope === "specific" && !targetEmployeeId) { + alert("Por favor selecciona un colaborador."); + return; + } + handleNext(); + }} + disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} + > +
+
setScheduleScope("me")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

Solo a mí

+
+
setScheduleScope("specific")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A un colaborador en particular

+
+
setScheduleScope("all")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A todos mis colaboradores

+
+ + {scheduleScope === "specific" && ( +
+ +
+ )} +
+
+ + +
+
setDisableDuration("one-day")} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: disableDuration === "one-day" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${disableDuration === "one-day" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Solo por un día +
+
setDisableDuration("multi-day")} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: disableDuration === "multi-day" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${disableDuration === "multi-day" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Por varios días +
+
+
+ + {disableDuration === "one-day" && ( + <> + +
+ setDisableStartDate(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '16px', fontFamily: 'sans-serif' + }} + /> +
+
+ + { + if (isFullDayDisabled) { + handleDisableScheduleSubmit(); + } else if (disableStartTime && disableEndTime) { + handleDisableScheduleSubmit(); + } + }} + nextLabel={isCreating ? "Guardando..." : "Cerrar Agenda"} + disableNext={(!isFullDayDisabled && (!disableStartTime || !disableEndTime)) || isCreating} + > +
+
+
setIsFullDayDisabled(true)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: isFullDayDisabled === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${isFullDayDisabled === true ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Todo el día +
+
setIsFullDayDisabled(false)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: isFullDayDisabled === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${isFullDayDisabled === false ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Franja Específica +
+
+ {!isFullDayDisabled && ( +
+
+ + setDisableStartTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ + setDisableEndTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ )} +
+
+ + )} + + {disableDuration === "multi-day" && ( + <> + +
+
+ + setDisableStartDate(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '16px', fontFamily: 'sans-serif' + }} + /> +
+
+ + setDisableStartTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + +
+
+ + setDisableEndDate(e.target.value)} + min={disableStartDate} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '16px', fontFamily: 'sans-serif' + }} + /> +
+
+ + setDisableEndTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + )} + + ); + } diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/NewCollaboratorFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/NewCollaboratorFlow.tsx new file mode 100644 index 0000000..5583f3f --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/NewCollaboratorFlow.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import ReactAvatar from "react-avatar"; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import QuestionInput from "../../QuestionCard/QuestionInput"; + +type Props = any; + +export default function NewCollaboratorFlow(props: Props) { + const { currentStepIndex, inviteEmail, setInviteEmail, inviteEmailError, setInviteEmailError, isSearchingEmail, handleSearchCollaboratorEmail, handleKeyDown, inviteCollaborator, inviteRole, setInviteRole, handleNext, handleCreateCollaborator, isCreating, createdCompanyId, router, resetInviteFlow, inviteFlowNotice, isUpgradeRequired } = props; + + return ( + <> + + { setInviteEmail(e.target.value); setInviteEmailError(""); }} + onKeyDown={(e) => { + if (inviteEmail.includes("@") && !isSearchingEmail) handleKeyDown(e, handleSearchCollaboratorEmail); + }} + autoFocus + /> + {inviteEmailError && ( +
+ No encontramos este e-mail.
+ Asegúrate de que la persona ya haya creado una cuenta en TurnosXpress con este correo. Pídele que se registre primero y vuelve a intentarlo. +
+ )} +
+ + + {inviteCollaborator && ( +
+ {inviteCollaborator.avatar ? ( + Avatar + ) : ( + + )} +
{inviteCollaborator.fullName}
+
+ )} +
+
setInviteRole("colaborador")} style={{ padding: '15px', borderRadius: '12px', background: inviteRole === "colaborador" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', border: `1px solid ${inviteRole === "colaborador" ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}> +
Colaborador
+
Solo podrá ver y gestionar su propia agenda de turnos.
+
+
setInviteRole("admin")} style={{ padding: '15px', borderRadius: '12px', background: inviteRole === "admin" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', border: `1px solid ${inviteRole === "admin" ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}> +
Administrador
+
Tendrá acceso total para modificar horarios, servicios y otros colaboradores de esta organización.
+
+
+
+ + {null} + + +
+ + router.push(`/admin/assistant?flow=update-schedule${createdCompanyId ? `&companyId=${createdCompanyId}` : ""}`)} title="Configurar mis horarios" description="Define tus horarios personales de atención." /> + router.push("/admin/assistant")} title="Terminar" description="Volver a la home del asistente." /> +
+
+ + +
+ + router.push("/admin/assistant")} title="Volver al asistente" description="Regresar a la home del asistente." /> +
+
+ + +
+ + router.push("/admin/assistant")} title="Volver al asistente" description="Regresar a la home del asistente." /> +
+
+ + window.location.href = "/landing/pricing"} nextLabel="Ver Planes y Mejorar"> +
+
🚀
+

Beneficios de expandirte

+
    +
  • Agendas ilimitadas para todo tu equipo.
  • +
  • Aumento exponencial de tus reservas diarias.
  • +
  • Control total sobre los permisos de cada integrante.
  • +
+
+
+ + ); +} + +function ActionCard({ onClick, title, description }: Props) { + return ( +
+
{title}
+
{description}
+
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/NotificationsFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/NotificationsFlow.tsx new file mode 100644 index 0000000..b43d839 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/NotificationsFlow.tsx @@ -0,0 +1,366 @@ +// @ts-nocheck +import React from "react"; +import SearchIcon from '@mui/icons-material/Search'; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import ClientPicker from "../../ClientPicker/ClientPicker"; +import Switch from "@components/Switch/Switch"; +import TextTime from "@components/TextTime/TextTime"; + +type Props = any; + +export default function NotificationsFlow(props: Props) { + const { currentStepIndex, notificationsScope, setNotificationsScope, handleNext, notifLoading, NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS, notifChannels, notifReminderRules, toggleNotifReminder, notifQuietHoursActive, setNotifQuietHoursActive, dayjsFromHHmm, notifQuietHoursFrom, setNotifQuietHoursFrom, notifQuietHoursTo, setNotifQuietHoursTo, handleSaveNotifications, isCreating, notifSelectedClientId, setNotifSelectedClientId, notifClients, notifClientPickerOpen, setNotifClientPickerOpen, setNotifChannels, toggleNotifChannel, handleSelectNotifScope } = props; + const renderNotificationsChannelToggle = (channel: NotificationChannel) => ( +
+
+ {NOTIF_CHANNELS.find(c => c.key === channel)?.label} + + {NOTIF_CHANNELS.find(c => c.key === channel)?.description} + +
+ toggleNotifChannel(channel)} + disabled={notifLoading} + /> +
+ ); + + return (() => { + return ( + <> + { + if (!notificationsScope) return; + handleNext(); + }} + disableNext={!notificationsScope} + nextLabel="Siguiente" + > +
+
handleSelectNotifScope("org")} + style={{ + padding: "18px", borderRadius: "12px", cursor: "pointer", + background: notificationsScope === "org" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", + border: `2px solid ${notificationsScope === "org" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, + color: "white", textAlign: "center", transition: "all 0.2s ease" + }} + > +

+ A la organización en general +

+

+ Configura canales, recordatorios y horario silencioso para todos los clientes. +

+
+
handleSelectNotifScope("client")} + style={{ + padding: "18px", borderRadius: "12px", cursor: "pointer", + background: notificationsScope === "client" ? "var(--wine-red)" : "rgba(255,255,255,0.05)", + border: `2px solid ${notificationsScope === "client" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, + color: "white", textAlign: "center", transition: "all 0.2s ease" + }} + > +

+ A un cliente específico +

+

+ Elige un cliente y define qué canales de notificación recibe. +

+
+
+
+ Estos ajustes se sincronizan con la página de notificaciones de tu organización, así que después podés seguir ajustándolos desde ahí. +
+
+ + {notificationsScope === "org" && ( + <> + +
+ {NOTIF_CHANNELS.map(c => renderNotificationsChannelToggle(c.key))} +
+
+ + +
+ {NOTIF_REMINDER_PRESETS.map(preset => { + const isActive = notifReminderRules.some(r => r.offset === preset.minutes); + return ( +
toggleNotifReminder(preset.minutes)} + style={{ + padding: "10px 18px", borderRadius: "999px", cursor: "pointer", + background: isActive ? "var(--wine-red)" : "rgba(255,255,255,0.05)", + border: `1px solid ${isActive ? "var(--wine-red)" : "rgba(255,255,255,0.15)"}`, + color: "white", + fontWeight: isActive ? "bold" : "normal", + transition: "all 0.2s ease", + fontSize: "14px" + }} + > + {preset.label} +
+ ); + })} +
+
+ {notifReminderRules.length === 0 + ? "No hay recordatorios activos todavía." + : `Recordatorios activos: ${notifReminderRules.length}.`} +
+
+ + +
+
+ Activar horario silencioso + setNotifQuietHoursActive(checked)} + disabled={notifLoading} + /> +
+ {notifQuietHoursActive && ( +
+
+ setNotifQuietHoursFrom(v.format("HH:mm"))} + /> +
+
+ setNotifQuietHoursTo(v.format("HH:mm"))} + /> +
+
+ )} +
+
+ + +
+
+
Canales activos
+
+ {notifChannels.length > 0 + ? notifChannels.map(c => NOTIF_CHANNELS.find(nc => nc.key === c)?.label).join(", ") + : "Ninguno"} +
+
+
+
Recordatorios
+
+ {notifReminderRules.length > 0 + ? notifReminderRules.map(r => NOTIF_REMINDER_PRESETS.find(p => p.minutes === r.offset)?.label || `${r.offset} min`).join(", ") + : "Ninguno"} +
+
+
+
Horario silencioso
+
+ {notifQuietHoursActive ? `De ${notifQuietHoursFrom} a ${notifQuietHoursTo}` : "Desactivado"} +
+
+
+
+ + )} + + {notificationsScope === "client" && ( + <> + { + if (!notifSelectedClientId) return; + handleNext(); + }} + disableNext={!notifSelectedClientId || notifLoading} + nextLabel={notifLoading ? "Cargando..." : "Siguiente"} + > +
+ {notifClients.length === 0 ? ( +
+ No hay clientes para esta organización todavía. Cuando alguien reserve un turno, aparecerá acá. +
+ ) : notifSelectedClientId ? (() => { + const sc = notifClients.find(c => c.id === notifSelectedClientId); + const sub = sc ? ((sc.email && sc.email.trim() !== "") ? sc.email : [sc.phoneCountryCode, sc.phoneAreaCode, sc.phoneNumber].filter(p => p && String(p).trim() !== "").map(p => String(p).trim()).join(" ")).replace(/^(?!\+)/, "+") : ""; + return ( + + ); + })() : ( + + )} +
+
+ + +
+ {NOTIF_CHANNELS.map(c => renderNotificationsChannelToggle(c.key))} +
+
+ + +
+
+
Cliente
+
+ {notifClients.find(c => c.id === notifSelectedClientId)?.fullName || "—"} +
+
+
+
Canales activos
+
+ {notifChannels.length > 0 + ? notifChannels.map(c => NOTIF_CHANNELS.find(nc => nc.key === c)?.label).join(", ") + : "Ninguno"} +
+
+
+
+ + { + setNotifChannels([]); + setNotifSelectedClientId(id); + setNotifClientPickerOpen(false); + }} + onClose={() => setNotifClientPickerOpen(false)} + /> + + )} + + ); + })(); + + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingOrgFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingOrgFlow.tsx new file mode 100644 index 0000000..e1ee358 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingOrgFlow.tsx @@ -0,0 +1,478 @@ +import React from "react"; +import ReactAvatar from "react-avatar"; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import QuestionInput from "../../QuestionCard/QuestionInput"; +import OnboardingPhone from "../../OnboardingPhone/OnboardingPhone"; +import OnboardingLocation from "../../OnboardingLocation/OnboardingLocation"; +import SelectLocationMap from "@core/app/components/SelectLocationMap/SelectLocationMap"; +import { validatePhone } from "@core/helpers/validatePhone"; +import categories from "@models/Categories.type"; +import style from "../StepEngine.module.css"; + +type OnboardingOrgFlowProps = { + currentStepIndex: any; + SessionInfo: any; + imageError: any; + setImageError: any; + handleNext: any; + handleNextName: any; + orgName: any; + setOrgName: any; + nameError: any; + setNameError: any; + isCheckingName: any; + categoryId: any; + setCategoryId: any; + handleCreateCompany: any; + isCreating: any; + orgDescription: any; + setOrgDescription: any; + handleKeyDown: any; + handleUpdateLocation: any; + isStandaloneAction: any; + street: any; + streetNumber: any; + builingFloor: any; + buildingApartament: any; + state: any; + city: any; + block: any; + zipCode: any; + latitude: any; + longitude: any; + setStreet: any; + setStreetNumber: any; + setBuilingFloor: any; + setBuildingApartament: any; + setState: any; + setCity: any; + setBlock: any; + setZipCode: any; + setLatitude: any; + setLongitude: any; + handleUpdateContact: any; + phoneAreaCode: any; + phoneNumber: any; + setPhoneAreaCode: any; + setPhoneNumber: any; + handleUpdateRules: any; + heatMapFraction: any; + setHeatMapFraction: any; + appointmentTime: any; + setAppointmentTime: any; + cancellationTime: any; + setCancellationTime: any; + handleUploadHeader: any; + headerColor: any; + setHeaderColor: any; + headerFontColor: any; + setHeaderFontColor: any; + headerImage: any; + setHeaderImage: any; +}; + +export default function OnboardingOrgFlow(props: OnboardingOrgFlowProps) { + const { currentStepIndex, SessionInfo, imageError, setImageError, handleNext, handleNextName, orgName, setOrgName, nameError, setNameError, isCheckingName, categoryId, setCategoryId, handleCreateCompany, isCreating, orgDescription, setOrgDescription, handleKeyDown, handleUpdateLocation, isStandaloneAction, street, streetNumber, builingFloor, buildingApartament, state, city, block, zipCode, latitude, longitude, setStreet, setStreetNumber, setBuilingFloor, setBuildingApartament, setState, setCity, setBlock, setZipCode, setLatitude, setLongitude, handleUpdateContact, phoneAreaCode, phoneNumber, setPhoneAreaCode, setPhoneNumber, handleUpdateRules, heatMapFraction, setHeatMapFraction, appointmentTime, setAppointmentTime, cancellationTime, setCancellationTime, handleUploadHeader, headerColor, setHeaderColor, headerFontColor, setHeaderFontColor, headerImage, setHeaderImage } = props; + return ( + <> + setImageError(true)} + /> + ) : ( + + ) + } + > + {null} + + + + { + setOrgName(e.target.value); + if (nameError) setNameError(""); + }} + onKeyDown={(e) => { + if (orgName.trim().length >= 3) { + handleKeyDown(e, handleNextName); + } + }} + autoFocus + error={nameError} + /> + + + +
+ {categories.filter(c => c.id !== 0).map(cat => ( +
{ + setCategoryId(cat.id); + setTimeout(handleNext, 400); + }} + > +
+
{cat.name}
+
+ ))} +
+
+ + + setOrgDescription(e.target.value)} + onKeyDown={(e) => { + if (orgDescription.trim().length >= 10) { + handleKeyDown(e, handleCreateCompany); + } + }} + autoFocus + /> + + +
+ { + if (newData.street !== undefined) setStreet(newData.street); + if (newData.streetNumber !== undefined) setStreetNumber(newData.streetNumber); + if (newData.builingFloor !== undefined) setBuilingFloor(newData.builingFloor); + if (newData.buildingApartament !== undefined) setBuildingApartament(newData.buildingApartament); + if (newData.state !== undefined) setState(newData.state); + if (newData.city !== undefined) setCity(newData.city); + if (newData.block !== undefined) setBlock(newData.block); + if (newData.zipCode !== undefined) setZipCode(newData.zipCode); + if (newData.latitude !== undefined) setLatitude(newData.latitude); + if (newData.longitude !== undefined) setLongitude(newData.longitude); + }} + /> +
+
+ + +
+
+ { + setLatitude(loc.latitude); + setLongitude(loc.longitude); + }} + /> +
+
{ + if (!isCreating) handleUpdateLocation(); + }} + > + Omitir este paso +
+
+
+ + +
+ { + setPhoneAreaCode(area); + setPhoneNumber(num); + }} + onKeyDown={(e) => { + if (validatePhone(phoneAreaCode, phoneNumber).isValid) { + handleKeyDown(e, handleUpdateContact); + } + }} + /> +
+
+ + +
+
+
Fracción de la Agenda (Minutos)
+
+ {[15, 30, 45, 60, 120].map(val => ( +
setHeatMapFraction(val)} + style={{ + padding: '8px 16px', + borderRadius: '8px', + cursor: 'pointer', + background: heatMapFraction === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${heatMapFraction === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + fontWeight: heatMapFraction === val ? 'bold' : 'normal', + transition: 'all 0.2s ease' + }} + > + {val}m +
+ ))} +
+
+ +
+
Anticipación mínima para reservar (Horas)
+
+ {[1, 2, 12, 24, 48].map(val => ( +
setAppointmentTime(val)} + style={{ + padding: '8px 16px', + borderRadius: '8px', + cursor: 'pointer', + background: appointmentTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${appointmentTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + fontWeight: appointmentTime === val ? 'bold' : 'normal', + transition: 'all 0.2s ease' + }} + > + {val}h +
+ ))} +
+
+ +
+
Anticipación mínima para cancelar (Horas)
+
+ {[1, 2, 12, 24, 48].map(val => ( +
setCancellationTime(val)} + style={{ + padding: '8px 16px', + borderRadius: '8px', + cursor: 'pointer', + background: cancellationTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${cancellationTime === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + fontWeight: cancellationTime === val ? 'bold' : 'normal', + transition: 'all 0.2s ease' + }} + > + {val}h +
+ ))} +
+
+
+
+ + +
+
+
+ + setHeaderColor(e.target.value)} + style={{ width: '100%', height: '40px', border: 'none', borderRadius: '8px', cursor: 'pointer' }} + /> +
+
+ + setHeaderFontColor(e.target.value)} + style={{ width: '100%', height: '40px', border: 'none', borderRadius: '8px', cursor: 'pointer' }} + /> +
+
+ + {/* PREVIEW BOX */} +
+
+ Vista previa: +
+
+ {headerImage && ( + Preview + )} +
+ {orgName || "Tu Negocio"} +
+
+ Categoría seleccionada +
+
+
+ + +
+
+ + ); + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingScheduleFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingScheduleFlow.tsx new file mode 100644 index 0000000..e3b343b --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingScheduleFlow.tsx @@ -0,0 +1,381 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type OnboardingScheduleFlowProps = { + currentStepIndex: any; + handleNext: any; + doesProvideServices: any; + setDoesProvideServices: any; + createdCompanyId: any; + router: any; + workingDaysMode: any; + setWorkingDaysMode: any; + openTime: any; + setOpenTime: any; + closeTime: any; + setCloseTime: any; + hasBreak: any; + setHasBreak: any; + breakStart: any; + setBreakStart: any; + breakEnd: any; + setBreakEnd: any; + handleCreateSchedule: any; + isCreating: any; + headerColor: any; + headerImage: any; + headerFontColor: any; + orgName: any; + previewServices: any; + setServiceName: any; + setServiceDescription: any; + setServiceLength: any; + setServicePrice: any; + setServiceLimit: any; + setServiceImage: any; + setCreatedServiceId: any; + setIsAddingAnotherService: any; + setCurrentStepIndex: any; +}; + +export default function OnboardingScheduleFlow(props: OnboardingScheduleFlowProps) { + const { currentStepIndex, handleNext, doesProvideServices, setDoesProvideServices, createdCompanyId, router, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, handleCreateSchedule, isCreating, headerColor, headerImage, headerFontColor, orgName, previewServices, setServiceName, setServiceDescription, setServiceLength, setServicePrice, setServiceLimit, setServiceImage, setCreatedServiceId, setIsAddingAnotherService, setCurrentStepIndex } = props; + return ( + <> + +
+ Con esta información, tu agenda online comenzará a aceptar reservas automáticamente. +
+
+ + { + if (doesProvideServices === null) return; + if (!doesProvideServices) { + const query = createdCompanyId ? `&companyId=${createdCompanyId}` : ""; + router.push(`/admin/assistant?flow=new-collaborator${query}`); + } else { + handleNext(); + } + }} + disableNext={doesProvideServices === null} + nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"} + > +
+
setDoesProvideServices(true)} + style={{ + padding: '20px 24px', + borderRadius: '12px', + cursor: 'pointer', + background: doesProvideServices === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${doesProvideServices === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, + color: 'white', + transition: 'all 0.2s ease', + textAlign: 'center', + flex: '1', + minWidth: '200px' + }} + > +

Sí, yo atiendo a los clientes

+

Asignaremos los servicios a tu nombre.

+
+
setDoesProvideServices(false)} + style={{ + padding: '20px 24px', + borderRadius: '12px', + cursor: 'pointer', + background: doesProvideServices === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${doesProvideServices === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, + color: 'white', + transition: 'all 0.2s ease', + textAlign: 'center', + flex: '1', + minWidth: '200px' + }} + > +

No, tengo un equipo

+

Te guiaremos para invitar al primer colaborador.

+
+
+
+ + +
+ {[ + { value: "mon-fri", label: "Lunes a Viernes" }, + { value: "mon-sat", label: "Lunes a Sábado" }, + { value: "mon-sun", label: "Lunes a Domingo" }, + ].map(opt => ( +
setWorkingDaysMode(opt.value)} + style={{ + padding: '12px 24px', + borderRadius: '12px', + cursor: 'pointer', + background: workingDaysMode === opt.value ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${workingDaysMode === opt.value ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, + color: 'white', + fontWeight: workingDaysMode === opt.value ? 'bold' : 'normal', + transition: 'all 0.2s ease', + fontSize: '16px' + }} + > + {opt.label} +
+ ))} +
+
+ + +
+
+ Apertura + setOpenTime(e.target.value)} + style={{ + padding: '12px', + borderRadius: '8px', + border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.05)', + color: 'white', + fontSize: '16px' + }} + /> +
+ a +
+ Cierre + setCloseTime(e.target.value)} + style={{ + padding: '12px', + borderRadius: '8px', + border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.05)', + color: 'white', + fontSize: '16px' + }} + /> +
+
+
+ + +
+
+
setHasBreak(true)} + style={{ + padding: '10px 20px', + borderRadius: '8px', + cursor: 'pointer', + background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + transition: 'all 0.2s ease' + }} + > + Sí, tengo descanso +
+
setHasBreak(false)} + style={{ + padding: '10px 20px', + borderRadius: '8px', + cursor: 'pointer', + background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + transition: 'all 0.2s ease' + }} + > + No, horario corrido +
+
+ + {hasBreak && ( +
+
+ Inicia el descanso + setBreakStart(e.target.value)} + style={{ + padding: '12px', + borderRadius: '8px', + border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.05)', + color: 'white', + fontSize: '16px' + }} + /> +
+ a +
+ Termina + setBreakEnd(e.target.value)} + style={{ + padding: '12px', + borderRadius: '8px', + border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.05)', + color: 'white', + fontSize: '16px' + }} + /> +
+
+ )} +
+
+ + +
+ + {/* Mockup visual */} +
+ {/* Cabecera Mockup */} +
+ {/* Simular imagen de cabecera si hay Archivo */} + {headerImage && ( + + )} +

{orgName || "Tu Negocio"}

+
+ + {/* Cuerpo Mockup (Servicios y Horarios) */} +
+
+

Servicios ({previewServices.length})

+ +
+
+ {previewServices.map((s: any, idx: number) => ( +
+
+ {s.name} + {s.length} min +
+ ${s.originalPrice} +
+ ))} + {previewServices.length === 0 && ( + Sin servicios configurados aún. + )} +
+ +

Horario Laboral

+
+
+ Días: {workingDaysMode === "mon-fri" ? "Lunes a Viernes" : (workingDaysMode === "mon-sat" ? "Lunes a Sábado" : "Lunes a Domingo")} +
+
+ Horario: {openTime} a {closeTime} +
+ {hasBreak && ( +
+ Descanso: {breakStart} a {breakEnd} +
+ )} +
+
+
+ + {/* Advertencia Explícita */} +
+

🚨 Aviso de Publicación

+

+ Al hacer clic en Publicar y Activar Agenda, tu organización, servicios y esta primera jornada laboral serán publicados en TurnosXpress. +

+ ¡Tus clientes podrán empezar a reservar turnos de inmediato! Podrás editar todo más tarde desde tu panel. +

+
+
+
+ + ); + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx new file mode 100644 index 0000000..ecc3b8c --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx @@ -0,0 +1,224 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import QuestionInput from "../../QuestionCard/QuestionInput"; + +type OnboardingServiceFlowProps = { + heatMapFraction: any; + currentStepIndex: any; + handleNext: any; + serviceName: any; + setServiceName: any; + serviceDescription: any; + setServiceDescription: any; + handleKeyDown: any; + serviceLength: any; + setServiceLength: any; + handleCreateService: any; + isCreating: any; + servicePrice: any; + setServicePrice: any; + serviceLimit: any; + setServiceLimit: any; + handleUploadServiceImage: any; + serviceImage: any; + setServiceImage: any; +}; + +export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) { + const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, handleUploadServiceImage, serviceImage, setServiceImage } = props; + const fraction = heatMapFraction || 60; + const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; + + return ( + <> + +
+ Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil! +
+
+ + +
+ setServiceName(e.target.value)} + autoFocus + /> + setServiceDescription(e.target.value)} + onKeyDown={(e) => { + if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) { + handleKeyDown(e, handleNext); + } + }} + /> +
+
+ + +
+ {durationOptions.map(val => { + const hours = Math.floor(val / 60); + const minutes = val % 60; + const label = hours > 0 + ? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim() + : `${minutes} min`; + + return ( +
setServiceLength(val)} + style={{ + padding: '12px 24px', + borderRadius: '12px', + cursor: 'pointer', + background: serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, + color: 'white', + fontWeight: serviceLength === val ? 'bold' : 'normal', + transition: 'all 0.2s ease', + fontSize: '16px' + }} + > + {label} +
+ ); + })} +
+
+ + +
+
+
Precio Original
+
+ $ + setServicePrice(e.target.value)} + style={{ + width: '100%', + padding: '15px 15px 15px 35px', + borderRadius: '12px', + border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.05)', + color: 'white', + fontSize: '18px', + outline: 'none' + }} + /> +
+
+ +
+
Cupos Simultáneos (Por defecto 1)
+
+ {[1, 2, 3, 5, 10].map(val => ( +
setServiceLimit(val)} + style={{ + padding: '8px 16px', + borderRadius: '8px', + cursor: 'pointer', + background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', + border: `1px solid ${serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, + color: 'white', + fontWeight: serviceLimit === val ? 'bold' : 'normal', + transition: 'all 0.2s ease' + }} + > + {val} +
+ ))} +
+
+
+
+ + +
+
+ {serviceImage ? ( + Preview + ) : ( + Toca para seleccionar imagen + )} + { + if (e.target.files && e.target.files.length > 0) { + setServiceImage(e.target.files[0]); + } + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + opacity: 0, + cursor: 'pointer' + }} + /> +
+
+
+ + ); + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/OverrideScheduleFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/OverrideScheduleFlow.tsx new file mode 100644 index 0000000..3d601f7 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/OverrideScheduleFlow.tsx @@ -0,0 +1,218 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function OverrideScheduleFlow(props: Props) { + const { currentStepIndex, scheduleScope, setScheduleScope, targetEmployeeId, setTargetEmployeeId, allEmployees, handleNext, overrideDate, setOverrideDate, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleUpdateScheduleSubmit, handleOverrideScheduleSubmit, disableDuration, setDisableDuration, disableStartDate, setDisableStartDate, disableEndDate, setDisableEndDate, isFullDayDisabled, setIsFullDayDisabled, disableStartTime, setDisableStartTime, disableEndTime, setDisableEndTime, handleDisableScheduleSubmit } = props; + return ( + <> + { + if (scheduleScope === "specific" && !targetEmployeeId) { + alert("Por favor selecciona un colaborador."); + return; + } + handleNext(); + }} + disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} + > +
+
setScheduleScope("me")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

Solo a mí

+
+
setScheduleScope("specific")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A un colaborador en particular

+
+
setScheduleScope("all")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A todos mis colaboradores

+
+ + {scheduleScope === "specific" && ( +
+ +
+ )} +
+
+ + +
+ setOverrideDate(e.target.value)} + style={{ + padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'sans-serif' + }} + /> +
+
+ + +
+
+ + setOpenTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ + setCloseTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + { + if (hasBreak) { + handleNext(); + } else { + handleOverrideScheduleSubmit(); + } + }} + disableNext={hasBreak === null} + nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Excepción")} + > +
+
setHasBreak(true)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Sí, hay descanso +
+
setHasBreak(false)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + No, de corrido +
+
+
+ + +
+
+ + setBreakStart(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ + setBreakEnd(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + ); + } diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicLinkFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicLinkFlow.tsx new file mode 100644 index 0000000..66dadbf --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicLinkFlow.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import QrCode2Icon from "@mui/icons-material/QrCode2"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type PublicLinkFlowProps = { + options: any; + getPublicLink: any; + getPublicLinkQrUrl: any; + getSelectedOrganizationSlug: any; + setShowOnboardingPublicLink: any; + setIsSuccess: any; + router: any; + selectedOrganization: any; + orgName: any; + handleCopyPublicLink: any; + publicLinkCopied: any; +}; + +export default function PublicLinkFlow(props: PublicLinkFlowProps) { + const { options, getPublicLink, getPublicLinkQrUrl, getSelectedOrganizationSlug, setShowOnboardingPublicLink, setIsSuccess, router, selectedOrganization, orgName, handleCopyPublicLink, publicLinkCopied } = props; + const isOnboardingCompletion = options?.onboardingCompletion === true; + const publicLink = getPublicLink(); + const publicLinkQrUrl = getPublicLinkQrUrl(); + const orgSlug = getSelectedOrganizationSlug(); + + return ( + { + if (isOnboardingCompletion) { + setShowOnboardingPublicLink(false); + setIsSuccess(true); + return; + } + + router.push("/admin/assistant"); + }} + nextLabel={isOnboardingCompletion ? "Finalizar" : "Volver al asistente"} + topAccessory={} + > +
+
+ Organización: {selectedOrganization?.name || orgName || "Sin organización seleccionada"} + {orgSlug && ( +
+ Slug público: {orgSlug} +
+ )} +
+ + {publicLink ? ( + <> +
+ Link público + + {publicLink} + +
+ +
+ + + + Descargar QR + +
+ +
+
+ {`Código +
+ + Este QR apunta al mismo link público. Si cambiás el nombre o slug de la organización, generá y compartí el QR actualizado. + +
+ + ) : ( +
+ No encontramos un slug para esta organización. Revisá el nombre de la organización en la configuración del perfil y volvé a intentar. +
+ )} +
+
+ ); + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicVisibilityFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicVisibilityFlow.tsx new file mode 100644 index 0000000..8ae440a --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/PublicVisibilityFlow.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function PublicVisibilityFlow(props: Props) { + const { isSelectedOrganizationPaidPlan, router, createdCompanyId, selectedOrganization, handleSavePublicVisibility, isCreating, orgShowPublicScores, setOrgShowPublicScores, orgShowPublicOpinions, setOrgShowPublicOpinions, orgShowPublicProfessionals, setOrgShowPublicProfessionals, servicesShowPublicScores, setServicesShowPublicScores, servicesShowPublicOpinions, setServicesShowPublicOpinions } = props; + const renderVisibilityCheckbox = (label: string, description: string, checked: boolean, onChange: (checked: boolean) => void) => ( + + ); + + return (() => { + if (!isSelectedOrganizationPaidPlan()) { + return ( + router.push(createdCompanyId ? `/landing/upgrade-plan?org=${createdCompanyId}` : "/landing/pricing")} + nextLabel="Ver planes" + > +
+ Estás configurando {selectedOrganization?.name ? {selectedOrganization.name} : "esta organización"}. Para cambiar qué opiniones, puntuaciones y profesionales se muestran públicamente, el dueño de esta organización necesita un plan pago. +
+
+ ); + } + + return ( + +
+
+

Organización

+ {renderVisibilityCheckbox("Mostrar puntuación pública", "Permite que los clientes vean la puntuación promedio de la organización.", orgShowPublicScores, setOrgShowPublicScores)} + {renderVisibilityCheckbox("Mostrar opiniones públicas", "Permite que los clientes vean las opiniones recibidas por la organización.", orgShowPublicOpinions, setOrgShowPublicOpinions)} + {renderVisibilityCheckbox("Mostrar profesionales públicos", "Permite que los clientes vean la lista de profesionales de la organización.", orgShowPublicProfessionals, setOrgShowPublicProfessionals)} +
+ +
+

Servicios

+ {renderVisibilityCheckbox("Mostrar puntuación pública de servicios", "Aplica este valor a todos los servicios de la organización.", servicesShowPublicScores, setServicesShowPublicScores)} + {renderVisibilityCheckbox("Mostrar opiniones públicas de servicios", "Aplica este valor a todos los servicios de la organización.", servicesShowPublicOpinions, setServicesShowPublicOpinions)} +
+
+
+ ); + })(); + + +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/ReservationPeriodFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/ReservationPeriodFlow.tsx new file mode 100644 index 0000000..3a79ab2 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/ReservationPeriodFlow.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import dayjs from "dayjs"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function ReservationPeriodFlow(props: Props) { + const { reservationPeriodEmployeesLoading, currentStepIndex, reservationPeriodEmployeesError, allEmployees, reservationPeriodScope, setReservationPeriodScope, targetEmployeeId, setTargetEmployeeId, reservationPeriodSummary, handleNext, reservationPeriodDuration, setReservationPeriodDuration, reservationPeriodOptions, handleExtendReservationPeriods, isCreating, selectedOrganization, orgName } = props; + + if (reservationPeriodEmployeesLoading) { + return
; + } + + if (reservationPeriodEmployeesError || allEmployees.length === 0) { + return
; + } + + const needsEmployeeSelection = allEmployees.length > 1 && reservationPeriodScope === "employee"; + const selectedEmployee = allEmployees.find((employee: any) => String(employee.id || employee._id || employee.employeeId) === String(targetEmployeeId)); + const reservationPeriodEmployees = reservationPeriodSummary?.employees || []; + const getReservationPeriodEmployeeSummary = (employee: any) => { + const employeeKey = employee.id || employee._id || employee.employeeId; + return reservationPeriodEmployees.find((summary: any) => String(summary.employeeId) === String(employeeKey)); + }; + const getReservationPeriodStatus = (employee: any) => { + const summary = getReservationPeriodEmployeeSummary(employee); + const daysRemaining = summary?.daysRemaining; + const safeDaysRemaining = typeof daysRemaining === "number" ? Math.max(daysRemaining, 0) : 0; + const progress = Math.min(Math.round((safeDaysRemaining / 30) * 100), 100); + const color = !summary?.isOpen || safeDaysRemaining === 0 ? "#ef4444" : safeDaysRemaining <= 7 ? "#f59e0b" : "#22c55e"; + + let label = "Sin período abierto"; + if (summary?.isOpen && typeof daysRemaining === "number") { + if (daysRemaining < 0) label = "Vencido"; + else if (daysRemaining === 1) label = "1 día restante"; + else label = `${daysRemaining} días restantes`; + } else if (typeof daysRemaining === "number" && daysRemaining < 0) { + label = "Vencido"; + } + + return { + label, + color, + progress, + openUntilLabel: summary?.currentOpenUntil ? `Abierto hasta ${dayjs(summary.currentOpenUntil).format("D [de] MMMM")}` : null + }; + }; + + return ( + <> + {allEmployees.length > 1 && ( + +
+ { setReservationPeriodScope("all"); setTargetEmployeeId(null); }} title="Todos los colaboradores" description="Extiende el período de reservas para todo el equipo." /> + setReservationPeriodScope("employee")} title="Un colaborador específico" description="Elige una persona del equipo." /> + {needsEmployeeSelection && ( +
+ {allEmployees.map((employee: any) => { + const employeeKey = employee.id || employee._id || employee.employeeId; + const isSelected = String(targetEmployeeId) === String(employeeKey); + const periodStatus = getReservationPeriodStatus(employee); + return ( +
setTargetEmployeeId(employeeKey)} style={{ padding: "12px 14px", borderRadius: "10px", cursor: "pointer", background: isSelected ? "rgba(255,42,127,0.18)" : "rgba(255,255,255,0.04)", border: `1px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, color: "white" }}> +
+
+ {employee.fullName || `${employee.firstName || ""} ${employee.lastName || ""}`.trim() || employee.email || "Colaborador"} + {periodStatus.label} +
+
+
0 ? "8px" : 0, height: "100%", borderRadius: "999px", background: periodStatus.color, transition: "width 0.2s ease" }} /> +
+ {periodStatus.openUntilLabel && {periodStatus.openUntilLabel}} +
+
+ ); + })} +
+ )} +
+ + )} + + 1 ? 1 : 0)} title="Elegí cuánto extender" description="Abriremos un nuevo bloque continuo desde el último día actualmente disponible." onNext={handleNext} disableNext={!reservationPeriodDuration} nextLabel="Siguiente"> +
+ {reservationPeriodOptions.map((option: any) => { + const isSelected = reservationPeriodDuration?.unit === option.unit && reservationPeriodDuration.value === option.value; + return
setReservationPeriodDuration({ unit: option.unit, value: option.value })} style={{ padding: "12px 24px", borderRadius: "12px", cursor: "pointer", background: isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.05)", border: `2px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`, color: "white", fontWeight: isSelected ? "bold" : "normal", transition: "all 0.2s ease", fontSize: "16px" }}>{option.label}
; + })} +
+
+ + 1 ? 2 : 1)} title="Confirmar extensión" description="Revisá la selección antes de abrir el nuevo período de reservas." onNext={handleExtendReservationPeriods} disableNext={isCreating || reservationPeriodEmployeesLoading || Boolean(reservationPeriodEmployeesError) || allEmployees.length === 0 || !reservationPeriodScope || !reservationPeriodDuration || (reservationPeriodScope === "employee" && !targetEmployeeId)} nextLabel={isCreating ? "Extendiendo..." : "Extender período"}> +
+ + + option.unit === reservationPeriodDuration?.unit && option.value === reservationPeriodDuration.value)?.label} /> + {reservationPeriodSummary?.worstDaysRemaining !== null && reservationPeriodSummary?.worstDaysRemaining !== undefined && ( +
Menor disponibilidad actual: {reservationPeriodSummary.worstDaysRemaining} días restantes.
+ )} +
+
+ + ); +} + +function SelectionCard({ selected, onClick, title, description }: Props) { + return

{title}

{description}

; +} + +function SummaryLine({ label, value }: Props) { + return
{label}
{value}
; +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/SetupCollaboratorFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/SetupCollaboratorFlow.tsx new file mode 100644 index 0000000..45ecafe --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/SetupCollaboratorFlow.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import QuestionInput from "../../QuestionCard/QuestionInput"; +import style from "../StepEngine.module.css"; + +type Props = any; + +const DAY_LABELS = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; +const getDayLabel = (day: number) => DAY_LABELS[day]; + +export default function SetupCollaboratorFlow(props: Props) { + const { currentStepIndex, collaboratorToSetup, setupMissingServices, setupMissingSchedules, setupSelectedServices, setSetupSelectedServices, setupAvailableServices, workingDaysMode, setWorkingDaysMode, customDays, setCustomDays, unifiedCustomSchedule, setUnifiedCustomSchedule, customSchedulesMap, setCustomSchedulesMap, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleSetupIntroNext, handleSetupServicesNext, handleSetupSchedulesNext } = props; + + const canProceed = () => { + if (!workingDaysMode) return false; + if (workingDaysMode === "custom") { + if (customDays.length === 0) return false; + if (unifiedCustomSchedule === null) return false; + } + return true; + }; + + if (currentStepIndex === 0) { + return ( + +
+
⚠️
+

Hemos detectado que a este colaborador le falta:

+
+ {setupMissingServices && } + {setupMissingSchedules && } +
+

Te guiaremos paso a paso para configurarlo rápidamente.

+
+
+ ); + } + + if (currentStepIndex === 1) { + return ( + +
+ {setupAvailableServices.map((service: any) => { + const serviceId = service.id || service._id; + const selected = setupSelectedServices.includes(serviceId); + return ( +
setSetupSelectedServices((prev: string[]) => prev.includes(serviceId) ? prev.filter(i => i !== serviceId) : [...prev, serviceId])} style={{ padding: "15px", borderRadius: "10px", background: selected ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.05)", border: `1px solid ${selected ? "#fff" : "rgba(255,255,255,0.1)"}`, cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center" }}> +
+
{service.name}
+
{service.duration} min - ${service.price}
+
+ {selected &&
} +
+ ); + })} + {setupAvailableServices.length === 0 &&
No hay servicios creados en la organización.
} +
+
+ ); + } + + if (currentStepIndex === 2) { + return ( + handleSetupSchedulesNext(canProceed)} nextLabel="Guardar Horarios" disableNext={isCreating || !canProceed()}> +
+ setWorkingDaysMode("mon-fri")} title="Lunes a Viernes" description="Horario comercial estándar" /> + setWorkingDaysMode("mon-sat")} title="Lunes a Sábado" description="Jornada extendida" /> + setWorkingDaysMode("mon-sun")} title="Todos los días" description="Lunes a Domingo" /> + setWorkingDaysMode("custom")} title="Otros / Personalizado" description="Selecciona días específicos y horarios variables" /> +
+ + {workingDaysMode === "custom" && ( +
+

Selecciona los días:

+
+ {[1,2,3,4,5,6,0].map(day => ( +
setCustomDays((prev: number[]) => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort())} style={{ padding: "8px 16px", borderRadius: "20px", cursor: "pointer", background: customDays.includes(day) ? "var(--wine-red)" : "rgba(255,255,255,0.1)", color: "white", fontWeight: customDays.includes(day) ? "bold" : "normal", transition: "all 0.2s", border: `1px solid ${customDays.includes(day) ? "var(--wine-red)" : "transparent"}` }}> + {getDayLabel(day)} +
+ ))} +
+ {customDays.length > 0 && ( +
+

¿Utiliza el mismo horario para todos estos días?

+
+ + +
+
+ )} +
+ )} + + {((workingDaysMode && workingDaysMode !== "custom") || (workingDaysMode === "custom" && unifiedCustomSchedule === true && customDays.length > 0)) && ( + + )} + + {workingDaysMode === "custom" && unifiedCustomSchedule === false && customDays.length > 0 && ( +
+ {customDays.map((day: number) => { + const ds = customSchedulesMap[day] || { openTime: "09:00", closeTime: "18:00", hasBreak: false, breakStart: "13:00", breakEnd: "14:00" }; + const updateDS = (updates: any) => setCustomSchedulesMap((prev: any) => ({...prev, [day]: { ...ds, ...updates } })); + return ; + })} +
+ )} +
+ ); + } + + return null; +} + +function MissingBadge({ icon, label }: Props) { + return
{icon} {label}
; +} + +function ModeCard({ active, onClick, title, description }: Props) { + return

{title}

{description}

; +} + +function TimeSettings(props: Props) { + const { openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd } = props; + return ( +
+

Horario de apertura y cierre

+
+ setOpenTime(e.target.value)} /> + setCloseTime(e.target.value)} /> +
+
+ setHasBreak(e.target.checked)} style={{ width: "18px", height: "18px" }} /> + +
+ {hasBreak &&
setBreakStart(e.target.value)} /> setBreakEnd(e.target.value)} />
} +
+ ); +} + +function CustomDaySettings({ day, ds, updateDS }: Props) { + return ( +
+

Configuración para {getDayLabel(day)}

+
+ updateDS({ openTime: e.target.value })} /> + updateDS({ closeTime: e.target.value })} /> +
+
+ updateDS({ hasBreak: e.target.checked })} style={{ width: "18px", height: "18px" }} /> + +
+ {ds.hasBreak &&
updateDS({ breakStart: e.target.value })} /> updateDS({ breakEnd: e.target.value })} />
} +
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx new file mode 100644 index 0000000..0cdf792 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = any; + +export default function UpdateScheduleFlow(props: Props) { + const { currentStepIndex, scheduleScope, setScheduleScope, targetEmployeeId, setTargetEmployeeId, allEmployees, handleNext, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleUpdateScheduleSubmit, handleOverrideScheduleSubmit, disableDuration, setDisableDuration, disableStartDate, setDisableStartDate, disableEndDate, setDisableEndDate, isFullDayDisabled, setIsFullDayDisabled, disableStartTime, setDisableStartTime, disableEndTime, setDisableEndTime, handleDisableScheduleSubmit } = props; + return ( + <> + { + if (scheduleScope === "specific" && !targetEmployeeId) { + alert("Por favor selecciona un colaborador."); + return; + } + handleNext(); + }} + disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} + > +
+
setScheduleScope("me")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

Solo a mí

+

Aplica esta rutina únicamente a tus horarios.

+
+
setScheduleScope("specific")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A un colaborador en particular

+

Aplica esta rutina a un integrante específico de tu equipo.

+
+
setScheduleScope("all")} + style={{ + padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center', transition: 'all 0.2s ease' + }} + > +

A todos mis colaboradores

+

Sobrescribe masivamente los horarios de todo tu equipo.

+
+ + {scheduleScope === "specific" && ( +
+ + +
+ )} +
+
+ + +
+ {[ + { id: 'mon-fri', label: 'Lunes a Viernes' }, + { id: 'mon-sat', label: 'Lunes a Sábado' }, + { id: 'mon-sun', label: 'Todos los días' } + ].map(mode => ( +
setWorkingDaysMode(mode.id)} + style={{ + padding: '15px', + borderRadius: '12px', + cursor: 'pointer', + background: workingDaysMode === mode.id ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${workingDaysMode === mode.id ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', + textAlign: 'center', + transition: 'all 0.2s ease' + }} + > + {mode.label} +
+ ))} +
+
+ + +
+
+ + setOpenTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ + setCloseTime(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + { + if (hasBreak) { + handleNext(); + } else { + handleUpdateScheduleSubmit(); + } + }} + disableNext={hasBreak === null} + nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Horarios")} + > +
+
setHasBreak(true)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + Sí, hay descanso +
+
setHasBreak(false)} + style={{ + flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', + background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`, + color: 'white', textAlign: 'center' + }} + > + No, horario corrido +
+
+
+ + +
+
+ + setBreakStart(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+ + setBreakEnd(e.target.value)} + style={{ + padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', + fontSize: '18px', fontFamily: 'monospace' + }} + /> +
+
+
+ + ); + } diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/WhatsAppBotFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/WhatsAppBotFlow.tsx new file mode 100644 index 0000000..6a2c161 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/WhatsAppBotFlow.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import WhatsAppIcon from "@mui/icons-material/WhatsApp"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type WhatsAppBotFlowProps = { + wapPhase: any; + wapAction: any; + wapView: any; + wapQr: any; + wapError: any; + createdCompanyId: any; + router: any; + loadWapView: any; + handleWapAssignServer: any; + handleWapCreateBot: any; + handleWapQuitServer: any; + handleWapStartBot: any; + handleWapDeleteBot: any; + handleWapVerifyQr: any; + handleWapStopBot: any; + getImageFromBase64: any; + setIsSuccess: any; + setWapPhase: any; + setWapError: any; +}; + +export default function WhatsAppBotFlow(props: WhatsAppBotFlowProps) { + const { wapPhase, wapAction, wapView, wapQr, wapError, createdCompanyId, router, loadWapView, handleWapAssignServer, handleWapCreateBot, handleWapQuitServer, handleWapStartBot, handleWapDeleteBot, handleWapVerifyQr, handleWapStopBot, getImageFromBase64, setIsSuccess, setWapPhase, setWapError } = props; + + return ( + <> + }> + {null} + + + router.push(`/landing/upgrade-plan?org=${createdCompanyId}`)} nextLabel="Ver planes" topAccessory={
📦
}> +
+ Actualizá tu plan para desbloquear notificaciones por WhatsApp y mejorar la comunicación con tus clientes. +
+
+ + 🖥️
}> + {wapAction === "assign" && } +
+ + 🤖
}> +
+ {wapAction === "create" && } + +
+
+ + ▶️
}> +
+ {wapAction === "start" && } + +
+
+ + 📱
}> +
+ {wapQr ? ( +
+ Escaneá el código QR +
+ ) : ( +
Cargando QR...
+ )} +
+ Abrí WhatsApp en tu teléfono → ConfiguraciónDispositivos vinculadosVincular dispositivo +
+ {wapError &&
{wapError}
} + +
+
+ + setIsSuccess(true)} nextLabel="Finalizar" topAccessory={
}> +
+
+ Sesión de WhatsApp activa — Bot operativo +
+ +
+
+ + { setWapPhase("intro"); setWapError(null); }} nextLabel="Reintentar" topAccessory={
⚠️
}> +
+ Si el problema persiste, contactá al soporte de TurnosXpress. +
+
+ + + + ); +} + +function LoadingMessage({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +function SecondaryButton({ onClick, disabled, loading, loadingLabel, label }: any) { + return ( + + ); +} + +function Spinner({ size, borderWidth }: { size: number; borderWidth: number }) { + return
; +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/employees.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/employees.ts new file mode 100644 index 0000000..3e46237 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/employees.ts @@ -0,0 +1,26 @@ +export const getEmployeeId = (employee: any) => employee.id || employee._id || employee.employeeId; + +export const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId; + +export const getTargetEmployees = ( + allEmployees: any[], + scheduleScope: "me" | "specific" | "all" | null, + targetEmployeeId: string | null, + sessionUserId: string +) => { + if (scheduleScope === "me") { + const me = allEmployees.find((employee: any) => String(getEmployeeUserId(employee)) === String(sessionUserId)); + return me ? [me] : []; + } + + if (scheduleScope === "specific") { + const specific = allEmployees.find((employee: any) => String(getEmployeeId(employee)) === String(targetEmployeeId)); + return specific ? [specific] : []; + } + + if (scheduleScope === "all") { + return [...allEmployees]; + } + + return []; +}; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/images.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/images.ts new file mode 100644 index 0000000..349b8c5 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/images.ts @@ -0,0 +1,10 @@ +export 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); +}; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/publicLink.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/publicLink.ts new file mode 100644 index 0000000..bb5f161 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/publicLink.ts @@ -0,0 +1,16 @@ +import { getSlug } from "@core/app/helpers/Slug"; + +export const getSelectedOrganizationSlug = (selectedOrganization: any, orgName: string) => { + return selectedOrganization?.slug || getSlug(selectedOrganization?.name || orgName || ""); +}; + +export const getPublicLink = (selectedOrganization: any, orgName: string) => { + const slug = getSelectedOrganizationSlug(selectedOrganization, orgName); + const publicBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"; + return slug ? `${publicBaseUrl.replace(/\/$/, "")}/${slug}` : ""; +}; + +export const getPublicLinkQrUrl = (selectedOrganization: any, orgName: string) => { + const publicLink = getPublicLink(selectedOrganization, orgName); + return publicLink ? `https://api.qrserver.com/v1/create-qr-code/?size=260x260&format=png&data=${encodeURIComponent(publicLink)}` : ""; +}; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts new file mode 100644 index 0000000..5d948aa --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts @@ -0,0 +1,26 @@ +type ScheduleBlock = { from: string; to: string; disabled: boolean }; + +export const getWorkingDays = (workingDaysMode: string, customDays: number[] = []) => { + if (workingDaysMode === "mon-fri") return [1, 2, 3, 4, 5]; + if (workingDaysMode === "mon-sat") return [1, 2, 3, 4, 5, 6]; + if (workingDaysMode === "mon-sun") return [1, 2, 3, 4, 5, 6, 0]; + if (workingDaysMode === "custom") return customDays; + return []; +}; + +export const buildScheduleBlocks = ( + openTime: string, + closeTime: string, + hasBreak: boolean | null, + breakStart: string, + breakEnd: string +): ScheduleBlock[] => { + if (hasBreak) { + return [ + { from: openTime, to: breakStart, disabled: false }, + { from: breakEnd, to: closeTime, disabled: false } + ]; + } + + return [{ from: openTime, to: closeTime, disabled: false }]; +}; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/time.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/time.ts new file mode 100644 index 0000000..6dcdebd --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/time.ts @@ -0,0 +1,6 @@ +import dayjs from "dayjs"; + +export const dayjsFromHHmm = (hhmm: string) => { + const [h, m] = (hhmm || "00:00").split(":").map(Number); + return dayjs().hour(h || 0).minute(m || 0).second(0).millisecond(0); +}; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNewCollaboratorHandlers.ts b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNewCollaboratorHandlers.ts new file mode 100644 index 0000000..8fc64d9 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNewCollaboratorHandlers.ts @@ -0,0 +1,111 @@ +import API from "@services/Api.Service"; + +type UseNewCollaboratorHandlersDeps = { + inviteEmail: string; + inviteCollaborator: any | null; + inviteRole: "admin" | "colaborador"; + createdCompanyId: string | null; + sessionUserId: string; + setInviteEmail: (value: string) => void; + setInviteCollaborator: (value: any | null) => void; + setInviteEmailError: (value: string) => void; + setInviteRole: (value: "admin" | "colaborador") => void; + setIsSearchingEmail: (value: boolean) => void; + setIsCreating: (value: boolean) => void; + setIsUpgradeRequired: (value: boolean) => void; + setInviteFlowNotice: (value: string) => void; + setCurrentStepIndex: (value: number) => void; + handleNext: () => void; +}; + +export function useNewCollaboratorHandlers({ + inviteEmail, + inviteCollaborator, + inviteRole, + createdCompanyId, + sessionUserId, + setInviteEmail, + setInviteCollaborator, + setInviteEmailError, + setInviteRole, + setIsSearchingEmail, + setIsCreating, + setIsUpgradeRequired, + setInviteFlowNotice, + setCurrentStepIndex, + handleNext +}: UseNewCollaboratorHandlersDeps) { + const handleSearchCollaboratorEmail = async () => { + if (!inviteEmail || !inviteEmail.includes("@")) return; + setIsSearchingEmail(true); + setInviteEmailError(""); + setInviteCollaborator(null); + try { + const data = await API.post("users/get-by-email", { email: inviteEmail.trim().toLowerCase() }); + if (data && (data.id || data._id)) { + if ((data.id || data._id) === sessionUserId) { + setInviteEmailError("No puedes invitarte a ti mismo."); + setIsSearchingEmail(false); + return; + } + setInviteCollaborator(data); + handleNext(); + } else { + setInviteEmailError("Usuario no registrado"); + } + } catch (error: any) { + setInviteEmailError("Usuario no registrado"); + } finally { + setIsSearchingEmail(false); + } + }; + + const handleCreateCollaborator = async () => { + if (!inviteCollaborator || !createdCompanyId) return; + setIsCreating(true); + setInviteFlowNotice(""); + try { + await API.post("employees/create", { + companyId: createdCompanyId, + userId: inviteCollaborator.id || inviteCollaborator._id, + roles: inviteRole === "admin" ? ["admin"] : [], + hostOk: true, + sessionUser: sessionUserId + }); + handleNext(); + } catch (error: any) { + const errorMessage = error.desc || error.message || "Error al invitar al colaborador"; + const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + if (normalizedErrorMessage.includes("limite")) { + setIsUpgradeRequired(true); + } else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) { + setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió."); + setCurrentStepIndex(5); + } else if (normalizedErrorMessage.includes("ya pertenece")) { + setInviteFlowNotice("Este usuario ya pertenece a la compañía."); + setCurrentStepIndex(6); + } else { + setInviteFlowNotice(errorMessage); + setCurrentStepIndex(5); + } + } finally { + setIsCreating(false); + } + }; + + const resetInviteFlow = () => { + setInviteEmail(""); + setInviteCollaborator(null); + setInviteEmailError(""); + setInviteRole("colaborador"); + setIsUpgradeRequired(false); + setInviteFlowNotice(""); + setCurrentStepIndex(1); + }; + + return { + handleSearchCollaboratorEmail, + handleCreateCollaborator, + resetInviteFlow + }; +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNotificationsHandlers.ts b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNotificationsHandlers.ts new file mode 100644 index 0000000..43055e7 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useNotificationsHandlers.ts @@ -0,0 +1,117 @@ +import API from "@services/Api.Service"; +import type React from "react"; +import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model"; +import { ALL_NOTIF_CHANNELS } from "../constants/notifications"; + +type UseNotificationsHandlersDeps = { + createdCompanyId: string | null; + sessionUserId: string; + isCreating: boolean; + notificationsScope: "org" | "client" | null; + notifChannels: NotificationChannel[]; + notifQuietHoursActive: boolean; + notifQuietHoursFrom: string; + notifQuietHoursTo: string; + notifReminderRules: ReminderRule[]; + notifSelectedClientId: string | null; + setNotificationsScope: (value: "org" | "client") => void; + setNotifChannels: React.Dispatch>; + setNotifQuietHoursActive: (value: boolean) => void; + setNotifQuietHoursFrom: (value: string) => void; + setNotifQuietHoursTo: (value: string) => void; + setNotifReminderRules: React.Dispatch>; + setNotifSelectedClientId: (value: string | null) => void; + setIsCreating: (value: boolean) => void; + handleActionSuccess: () => void; +}; + +export function useNotificationsHandlers({ + createdCompanyId, + sessionUserId, + isCreating, + notificationsScope, + notifChannels, + notifQuietHoursActive, + notifQuietHoursFrom, + notifQuietHoursTo, + notifReminderRules, + notifSelectedClientId, + setNotificationsScope, + setNotifChannels, + setNotifQuietHoursActive, + setNotifQuietHoursFrom, + setNotifQuietHoursTo, + setNotifReminderRules, + setNotifSelectedClientId, + setIsCreating, + handleActionSuccess +}: UseNotificationsHandlersDeps) { + const toggleNotifChannel = (channel: NotificationChannel) => { + setNotifChannels(prev => + prev.includes(channel) ? prev.filter(c => c !== channel) : [...prev, channel] + ); + }; + + const toggleNotifReminder = (minutes: number) => { + const exists = notifReminderRules.some(r => r.offset === minutes); + if (exists) { + setNotifReminderRules(prev => prev.filter(r => r.offset !== minutes)); + } else { + setNotifReminderRules(prev => [...prev, { offset: minutes, enabled: true }]); + } + }; + + const handleSelectNotifScope = (scope: "org" | "client") => { + setNotificationsScope(scope); + setNotifChannels([]); + setNotifQuietHoursActive(false); + setNotifQuietHoursFrom("22:00"); + setNotifQuietHoursTo("08:00"); + setNotifReminderRules([]); + setNotifSelectedClientId(null); + }; + + const handleSaveNotifications = async () => { + if (!createdCompanyId || isCreating) return; + if (notificationsScope === "client" && !notifSelectedClientId) return; + setIsCreating(true); + try { + if (notificationsScope === "org") { + const quietHours = notifQuietHoursActive + ? { from: notifQuietHoursFrom, to: notifQuietHoursTo } + : null; + await API.post("notifications/policy/save", { + companyId: createdCompanyId, + sessionUser: sessionUserId, + defaultChannels: notifChannels, + timezone: "America/Argentina/Buenos_Aires", + quietHours: quietHours || undefined, + reminderRules: notifReminderRules + }); + } else if (notificationsScope === "client") { + const preferredChannels = notifChannels as NotificationChannel[]; + const mutedChannels = ALL_NOTIF_CHANNELS.filter(c => !preferredChannels.includes(c)); + await API.post("notifications/override/save", { + clientId: notifSelectedClientId, + companyId: createdCompanyId, + sessionUser: sessionUserId, + preferredChannels, + mutedChannels + }); + } + handleActionSuccess(); + } catch (error: any) { + console.error("Error guardando notificaciones:", error); + alert(error?.message || "Hubo un error al guardar las notificaciones."); + } finally { + setIsCreating(false); + } + }; + + return { + toggleNotifChannel, + toggleNotifReminder, + handleSelectNotifScope, + handleSaveNotifications + }; +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/hooks/useReservationPeriodHandlers.ts b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useReservationPeriodHandlers.ts new file mode 100644 index 0000000..60bf97d --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useReservationPeriodHandlers.ts @@ -0,0 +1,55 @@ +import API from "@services/Api.Service"; +import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; + +type UseReservationPeriodHandlersDeps = { + createdCompanyId: string | null; + sessionUserId: string; + isCreating: boolean; + reservationPeriodScope: "employee" | "all" | null; + reservationPeriodDuration: ReservationPeriodDuration | null; + reservationPeriodEmployeesLoading: boolean; + reservationPeriodEmployeesError: string | null; + allEmployees: any[]; + targetEmployeeId: string | null; + setIsCreating: (value: boolean) => void; + setIsSuccess: (value: boolean) => void; +}; + +export function useReservationPeriodHandlers({ + createdCompanyId, + sessionUserId, + isCreating, + reservationPeriodScope, + reservationPeriodDuration, + reservationPeriodEmployeesLoading, + reservationPeriodEmployeesError, + allEmployees, + targetEmployeeId, + setIsCreating, + setIsSuccess +}: UseReservationPeriodHandlersDeps) { + const handleExtendReservationPeriods = async () => { + if (!createdCompanyId || !reservationPeriodScope || !reservationPeriodDuration || isCreating) return; + if (reservationPeriodEmployeesLoading || reservationPeriodEmployeesError || allEmployees.length === 0) return; + if (reservationPeriodScope === "employee" && !targetEmployeeId) return; + + setIsCreating(true); + try { + await API.post("schedules-enabled/reservation-periods/extend", { + companyId: createdCompanyId, + scope: reservationPeriodScope, + employeeId: reservationPeriodScope === "employee" ? targetEmployeeId : undefined, + duration: reservationPeriodDuration, + sessionUser: sessionUserId + }); + setIsSuccess(true); + } catch (error: any) { + console.error("Error extendiendo período de reservas:", error); + alert(error?.message || "Hubo un error al extender el período de reservas."); + } finally { + setIsCreating(false); + } + }; + + return { handleExtendReservationPeriods }; +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/hooks/useWhatsAppBotHandlers.ts b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useWhatsAppBotHandlers.ts new file mode 100644 index 0000000..ef09364 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/hooks/useWhatsAppBotHandlers.ts @@ -0,0 +1,124 @@ +import API from "@services/Api.Service"; + +type UseWhatsAppBotHandlersDeps = { + createdCompanyId: string | null; + sessionUserId: string; + organizationSubscriptions: any; + wapAction: string | null; + setWapAction: (value: string | null) => void; + setWapError: (value: string | null) => void; + setWapPhase: (value: string) => void; + setWapQr: (value: string | null) => void; + setWapView: (value: any) => void; + setIsSuccess: (value: boolean) => void; +}; + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function useWhatsAppBotHandlers({ + createdCompanyId, + sessionUserId, + organizationSubscriptions, + wapAction, + setWapAction, + setWapError, + setWapPhase, + setWapQr, + setWapView, + setIsSuccess +}: UseWhatsAppBotHandlersDeps) { + const loadWapView = async () => { + if (!createdCompanyId) return; + setWapAction("load"); + setWapError(null); + try { + const view = await API.post("views/wap-server-view", { + companyId: createdCompanyId, + sessionUser: sessionUserId + }); + setWapView(view); + + const hasPlan = 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("wapserver/qr", { + companyId: createdCompanyId, + sessionUser: sessionUserId + }); + 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 runWapAction = async (actionName: string, endpoint: string, delayMs: number, errorMessage: string) => { + if (!createdCompanyId || wapAction) return; + setWapAction(actionName); + setWapError(null); + try { + await API.post(endpoint, { + companyId: createdCompanyId, + sessionUser: sessionUserId + }); + await wait(delayMs); + await loadWapView(); + } catch (err: any) { + console.error(`Error ${actionName} WAP bot:`, err); + setWapError(err?.message || errorMessage); + setWapPhase("error"); + } finally { + setWapAction(null); + } + }; + + const handleWapVerifyQr = async () => { + if (!createdCompanyId) return; + setWapAction("verify"); + try { + const view = await API.post("views/wap-server-view", { + companyId: createdCompanyId, + sessionUser: sessionUserId + }); + 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); + } + }; + + return { + loadWapView, + handleWapAssignServer: () => runWapAction("assign", "wapserver/assign", 2000, "Error al asignar el servidor."), + handleWapCreateBot: () => runWapAction("create", "wapserver/create", 5000, "Error al crear el bot."), + handleWapStartBot: () => runWapAction("start", "wapserver/start", 3000, "Error al iniciar el bot."), + handleWapStopBot: () => runWapAction("stop", "wapserver/stop", 2000, "Error al detener el bot."), + handleWapDeleteBot: () => runWapAction("delete", "wapserver/delete", 3000, "Error al eliminar el bot."), + handleWapQuitServer: () => runWapAction("quit", "wapserver/quit", 2000, "Error al desasignar el servidor."), + handleWapVerifyQr + }; +}