From 40295660638807e8f3f669f6f519dc1b92132099 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Wed, 22 Jul 2026 17:27:15 -0300 Subject: [PATCH] feat: add notification configuration flow to StepEngine and integrate ClientPicker component --- .../AssistantHome/AssistantHome.tsx | 10 + .../ClientPicker/ClientPicker.module.css | 370 +++++++++++++ .../components/ClientPicker/ClientPicker.tsx | 210 +++++++ .../components/StepEngine/StepEngine.tsx | 523 +++++++++++++++++- 4 files changed, 1111 insertions(+), 2 deletions(-) create mode 100644 txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.module.css create mode 100644 txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.tsx diff --git a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx index 392ef6b..24c2798 100644 --- a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx +++ b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx @@ -16,6 +16,7 @@ import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import ColorLensIcon from '@mui/icons-material/ColorLens'; import PinDropIcon from '@mui/icons-material/PinDrop'; import ReviewsIcon from '@mui/icons-material/Reviews'; +import NotificationsIcon from '@mui/icons-material/Notifications'; import API from "@services/Api.Service"; @@ -75,6 +76,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [ keywords: ["opiniones", "puntuaciones", "calificaciones", "profesionales", "visibilidad", "reseñas", "publico"], icon: ReviewsIcon }, + { + id: "notifications", + flowId: "notifications", + title: "Notificaciones", + description: "Configura canales de notificación, recordatorios y horario silencioso para la organización o un cliente.", + category: "Negocio", + keywords: ["notificaciones", "canales", "whatsapp", "email", "sistema", "recordatorio", "silencioso", "cliente"], + icon: NotificationsIcon + }, { id: "new-service", flowId: "onboarding-service&action=new-service", diff --git a/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.module.css b/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.module.css new file mode 100644 index 0000000..e839139 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.module.css @@ -0,0 +1,370 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 10000; + display: flex; + justify-content: center; + align-items: center; + animation: overlayFadeIn 0.25s ease-out; +} + +@keyframes overlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.sheet { + width: 100%; + max-width: 480px; + max-height: min(80vh, 600px); + background: linear-gradient(160deg, var(--wine-superdark) 0%, #1a0a2e 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + overflow: hidden; + animation: sheetIn 0.3s ease-out; + color: white; +} + +@keyframes sheetIn { + from { opacity: 0; transform: scale(0.97) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 20px 16px 20px; + flex-shrink: 0; +} + +.header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: white; + letter-spacing: -0.01em; + font-family: inherit; +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + transition: background 0.2s, color 0.2s, transform 0.25s; + flex-shrink: 0; +} + +.closeButton:hover { + background: rgba(255, 42, 127, 0.15); + color: var(--wine-red); + transform: rotate(90deg); +} + +.searchWrap { + position: relative; + margin: 0 20px 14px 20px; + flex-shrink: 0; +} + +.searchInput { + width: 100%; + box-sizing: border-box; + padding: 12px 44px 12px 44px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: white; + font-size: 15px; + font-family: inherit; + outline: none; + transition: border-color 0.25s, box-shadow 0.25s, background 0.25s; +} + +.searchInput::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.searchInput:focus { + border-color: var(--wine-red); + box-shadow: 0 0 0 3px rgba(255, 42, 127, 0.18); + background: rgba(255, 255, 255, 0.08); +} + +.clearButton { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + border: none; + background: rgba(255, 255, 255, 0.08); + border-radius: 50%; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background 0.2s; +} + +.clearButton:hover { + color: white; + background: rgba(255, 255, 255, 0.18); +} + +.results { + flex: 1 1 auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 4px 14px 18px 14px; + display: flex; + flex-direction: column; + gap: 8px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.results::-webkit-scrollbar { + width: 8px; +} + +.results::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.14); + border-radius: 8px; +} + +.clientCard { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: white; + cursor: pointer; + text-align: left; + font-family: inherit; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s, background 0.2s; +} + +.clientCard:hover, +.clientCard:focus-visible { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.07); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); + outline: none; +} + +.clientCardSelected { + border-color: var(--wine-red); + background: rgba(255, 42, 127, 0.08); + box-shadow: 0 0 24px rgba(255, 42, 127, 0.2), inset 0 0 0 1px rgba(255, 42, 127, 0.25); +} + +.clientCardSelected:hover, +.clientCardSelected:focus-visible { + background: rgba(255, 42, 127, 0.12); + border-color: var(--wine-red); +} + +.avatarWrap { + position: relative; + width: 44px; + height: 44px; + border-radius: 50%; + flex-shrink: 0; + overflow: hidden; +} + +.avatarFallback { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: white; + letter-spacing: 0.02em; + background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%); +} + +.avatarImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + transition: opacity 0.15s; +} + +.cardBody { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cardTitle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.fullName { + color: white; + font-weight: 700; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bannedChip { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ffd1cc; + background: rgba(255, 42, 42, 0.16); + border: 1px solid rgba(255, 42, 42, 0.32); + padding: 2px 7px; + border-radius: 999px; +} + +.subLine { + color: rgba(255, 255, 255, 0.55); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.checkWrap { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: white; + background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%); + box-shadow: 0 0 14px rgba(255, 42, 127, 0.45); +} + +.centerState { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.7); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.emptyTitle { + margin: 0; + color: white; + font-size: 16px; + font-weight: 700; +} + +.emptyText { + margin: 0; + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + line-height: 1.5; + max-width: 300px; +} + +.clearCta { + margin-top: 6px; + padding: 8px 16px; + border-radius: 999px; + border: 1px solid rgba(255, 42, 127, 0.4); + background: rgba(255, 42, 127, 0.1); + color: white; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.clearCta:hover { + background: rgba(255, 42, 127, 0.2); + border-color: var(--wine-red); +} + +.spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.18); + border-top-color: var(--wine-red); + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Mobile: bottom sheet */ +@media (max-width: 600px) { + .overlay { + align-items: flex-end; + } + + .sheet { + max-width: 100%; + width: 100%; + max-height: 82vh; + border-radius: 24px 24px 0 0; + border-left: none; + border-right: none; + border-bottom: none; + animation: slideUp 0.3s ease-out; + } + + .header { + padding: 18px 18px 12px 18px; + } + + .searchWrap { + margin: 0 14px 12px 14px; + } + + .results { + padding: 4px 10px 22px 10px; + } +} \ No newline at end of file diff --git a/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.tsx b/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.tsx new file mode 100644 index 0000000..4e9091f --- /dev/null +++ b/txclient/src/app/admin/assistant/components/ClientPicker/ClientPicker.tsx @@ -0,0 +1,210 @@ +"use client"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import style from "./ClientPicker.module.css"; +import CloseIcon from "@mui/icons-material/Close"; +import SearchIcon from "@mui/icons-material/Search"; +import CheckIcon from "@mui/icons-material/Check"; +import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined"; +import { OrganizationClientView } from "@models/Clients.model"; + +export interface ClientPickerProps { + open: boolean; + clients: OrganizationClientView[]; + selectedId: string | null; + onSelect: (id: string) => void; + onClose: () => void; + loading?: boolean; +} + +function formatPhone(c: OrganizationClientView): string { + const parts = [c.phoneCountryCode, c.phoneAreaCode, c.phoneNumber] + .filter(p => p != null && String(p).trim() !== ""); + if (parts.length === 0) return ""; + const joined = parts.map(p => String(p).trim()).join(" "); + return joined.startsWith("+") ? joined : `+${joined}`; +} + +function getInitials(c: OrganizationClientView): string { + const f = (c.firstName?.[0] || "").toUpperCase(); + const l = (c.lastName?.[0] || "").toUpperCase(); + return `${f}${l}` || "?"; +} + +export default function ClientPicker({ + open, + clients, + selectedId, + onSelect, + onClose, + loading, +}: ClientPickerProps) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + setQuery(""); + const t = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 80); + return () => { + window.removeEventListener("keydown", onKey); + clearTimeout(t); + }; + }, [open, onClose]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return clients; + return clients.filter(c => + (c.firstName || "").toLowerCase().includes(q) || + (c.lastName || "").toLowerCase().includes(q) || + (c.fullName || "").toLowerCase().includes(q) + ); + }, [clients, query]); + + if (!open) return null; + + const hasClients = clients.length > 0; + const noResults = hasClients && filtered.length === 0 && query.trim() !== ""; + const searching = query.trim() !== ""; + + return ( +
+
e.stopPropagation()} + > +
+

Selecciona un cliente

+ +
+ +
+ + setQuery(e.target.value)} + aria-label="Buscar cliente" + autoComplete="off" + spellCheck={false} + /> + {searching && ( + + )} +
+ +
+ {loading ? ( +
+
+

Cargando clientes…

+
+ ) : !hasClients ? ( +
+ +

Sin clientes todavía

+

+ No hay clientes para esta organización todavía. Cuando alguien reserve un turno, aparecerá acá. +

+
+ ) : noResults ? ( +
+

No encontramos clientes con ese nombre.

+ +
+ ) : ( + filtered.map(c => { + const isSelected = c.id === selectedId; + const sub = (c.email && c.email.trim() !== "") ? c.email : formatPhone(c); + const avatar = c.avatar && c.avatar.trim() !== ""; + return ( + + ); + }) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx index 2c7ca34..ce9991a 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx @@ -3,8 +3,10 @@ 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"; @@ -20,6 +22,10 @@ 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"; interface StepEngineProps { flowId: string; @@ -109,6 +115,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true); const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true); + // State for Notifications Flow + const [notificationsScope, setNotificationsScope] = useState<"org" | "client" | null>(null); + const [notifChannels, setNotifChannels] = useState([]); + const [notifQuietHoursActive, setNotifQuietHoursActive] = useState(false); + const [notifQuietHoursFrom, setNotifQuietHoursFrom] = useState("22:00"); + const [notifQuietHoursTo, setNotifQuietHoursTo] = useState("08:00"); + const [notifReminderRules, setNotifReminderRules] = useState([]); + const [notifClients, setNotifClients] = useState([]); + const [notifSelectedClientId, setNotifSelectedClientId] = useState(null); + const [notifLoading, setNotifLoading] = useState(false); + const [notifClientPickerOpen, setNotifClientPickerOpen] = useState(false); + // State for standalone update schedule flow const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null); const [targetEmployeeId, setTargetEmployeeId] = useState(null); @@ -282,11 +300,30 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }).catch(console.error); } + } else if (flowId === "notifications") { + setCurrentStepIndex(0); + setNotificationsScope(null); + setNotifChannels([]); + setNotifQuietHoursActive(false); + setNotifQuietHoursFrom("22:00"); + setNotifQuietHoursTo("08:00"); + setNotifReminderRules([]); + setNotifSelectedClientId(null); + API.post("clients/find", { + companyId: org.id || org._id, + page: 1, + limit: 100, + sessionUser: SessionInfo.userId + }) + .then(res => { + setNotifClients(res?.data || []); + }) + .catch(err => console.error("Error cargando clientes de la organización:", err)); } }; React.useEffect(() => { - if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") { + if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") { setIsResuming(true); API.post("companies/get-by-user", { sessionUser: SessionInfo.userId }) .then(response => { @@ -307,11 +344,56 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm .finally(() => setIsResuming(false)); } - if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || (flowId === "onboarding-org" && action === "new-org")) { + if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "notifications" || (flowId === "onboarding-org" && action === "new-org")) { setCurrentStepIndex(0); } }, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]); + React.useEffect(() => { + if (flowId !== "notifications") return; + if (!createdCompanyId) return; + if (notificationsScope !== "org") return; + setNotifLoading(true); + API.post("notifications/policy/find", { + companyId: createdCompanyId, + sessionUser: SessionInfo.userId + }) + .then(policy => { + setNotifChannels((policy?.defaultChannels || []) as NotificationChannel[]); + const qh = policy?.quietHours; + if (qh && qh.from && qh.to) { + setNotifQuietHoursActive(true); + setNotifQuietHoursFrom(qh.from); + setNotifQuietHoursTo(qh.to); + } else { + setNotifQuietHoursActive(false); + setNotifQuietHoursFrom("22:00"); + setNotifQuietHoursTo("08:00"); + } + setNotifReminderRules((policy?.reminderRules || []) as ReminderRule[]); + }) + .catch(err => console.error("Error cargando política de notificaciones:", err)) + .finally(() => setNotifLoading(false)); + }, [flowId, createdCompanyId, notificationsScope, SessionInfo.userId]); + + React.useEffect(() => { + if (flowId !== "notifications") return; + if (!createdCompanyId) return; + if (notificationsScope !== "client") return; + if (!notifSelectedClientId) return; + setNotifLoading(true); + API.post("notifications/override/find", { + clientId: notifSelectedClientId, + companyId: createdCompanyId, + sessionUser: SessionInfo.userId + }) + .then(override => { + setNotifChannels((override?.preferredChannels || []) as NotificationChannel[]); + }) + .catch(err => console.error("Error cargando override de notificaciones:", err)) + .finally(() => setNotifLoading(false)); + }, [flowId, createdCompanyId, notificationsScope, notifSelectedClientId, SessionInfo.userId]); + const handleBack = () => { let minStepIndex = 0; if (flowId === "onboarding-org" && action) { @@ -3389,6 +3471,441 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm ); }; + 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 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í. +
+
+ + {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)} + /> + + )} + + ); + }; + const renderFlow = () => { if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") { return ( @@ -3427,6 +3944,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow(); if (flowId === "new-collaborator") return renderNewCollaboratorFlow(); if (flowId === "public-visibility") return renderPublicVisibilityFlow(); + if (flowId === "notifications") return renderNotificationsFlow(); if (flowId === "disable-schedule") return renderDisableScheduleFlow(); if (flowId === "override-schedule") return renderOverrideScheduleFlow(); if (flowId === "update-schedule") return renderUpdateScheduleFlow(); @@ -3445,6 +3963,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm 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 (action) return "Tus cambios se han guardado exitosamente."; return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional."; };