import { useState, useEffect, useRef } from "react"; import { Box, useBreakpointValue } from "@chakra-ui/react"; import { Widget } from "./components/widget/Widget"; import FloatingActionButton from "./components/floating-action-button/FloatingActionButton"; import { useBotInitializer } from "./components/widget/useBotInitializer"; import { useChatContext } from "./providers/chat/chat-context"; import { useBannedPaths } from "./hooks/banned-path/useBannedPaths"; import { getPositionConfig, getFloatingButtonStyles, } from "./utils/positioning"; import { WidgetType } from "./utils/session-storage"; import { withErrorHandling } from "./utils/sentry"; import { setGoftinoLauncherHidden } from "./utils/goftino"; import { setRaychatLauncherHidden } from "./utils/raychat"; import { setGapifyLauncherHidden } from "./utils/gapify"; import { trackWidgetOpened, trackWidgetClosed, trackWidgetPresent, trackWidgetVisible, updateBotData, } from "./utils/posthog"; function AppContent() { const [isOpen, setIsOpen] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [widgetOpenTime, setWidgetOpenTime] = useState(null); const [messagesCountOnOpen, setMessagesCountOnOpen] = useState(0); const fabRef = useRef(null); const lastOpenedUrlRef = useRef(null); const { botData, messages, setShouldInjectContext } = useChatContext(); const { isCurrentPathBanned } = useBannedPaths(botData?.banned_paths || []); const isGoftinoChannelEnabled = Boolean( botData?.merchant_support_channels?.goftino?.enabled, ); const isRaychatChannelEnabled = Boolean( botData?.merchant_support_channels?.raychat?.enabled, ); const isGapifyChannelEnabled = Boolean( botData?.merchant_support_channels?.gapify?.enabled, ); useBotInitializer(WidgetType.FLOATING); // Update PostHog bot data and track widget_present when botData is available useEffect(() => { if (botData) { // Ensure PostHog has current bot data for all events (fixes missing bot_id) updateBotData(botData); trackWidgetPresent(WidgetType.FLOATING, botData).catch(() => { // Silently fail - tracking errors shouldn't break the widget }); } }, [botData]); // Track widget_visible when FAB is ≥50% visible in viewport useEffect(() => { if (!botData || isOpen) return; const fabElement = fabRef.current; if (!fabElement) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { trackWidgetVisible(WidgetType.FLOATING).catch(() => { // Silently fail - tracking errors shouldn't break the widget }); observer.disconnect(); } }, { threshold: 0.5 }, ); observer.observe(fabElement); return () => observer.disconnect(); }, [botData, isOpen]); const isMobile = useBreakpointValue({ base: true, md: false }) ?? false; const positionConfig = getPositionConfig(botData, isMobile); const floatingButtonStyles = getFloatingButtonStyles(positionConfig); const toggleChat = () => { setIsOpen(!isOpen); }; // Track widget opened/closed events useEffect(() => { if (isOpen && widgetOpenTime === null) { // Widget opened - track event and start session recording (lazy init) trackWidgetOpened(WidgetType.FLOATING, "fab_click", botData); setWidgetOpenTime(Date.now()); setMessagesCountOnOpen(messages.length); } else if (!isOpen && widgetOpenTime !== null) { // Widget closed - track session duration const sessionDuration = Math.floor((Date.now() - widgetOpenTime) / 1000); // in seconds const messagesSent = messages.length - messagesCountOnOpen; trackWidgetClosed(sessionDuration, messagesSent); setWidgetOpenTime(null); setMessagesCountOnOpen(0); } }, [isOpen]); // Only depend on isOpen to avoid loops // Detect when widget opens on a new page (widget_test mode only) useEffect(() => { if (!isOpen || !botData?.widget_test) { return; } const currentUrl = window.location.href; if (lastOpenedUrlRef.current !== currentUrl) { // Widget opened on a different page - inject context on next message setShouldInjectContext(true); lastOpenedUrlRef.current = currentUrl; } }, [isOpen, botData?.widget_test, setShouldInjectContext]); useEffect(() => { const shouldHideGoftinoByState = isOpen; document.body.setAttribute( "data-mori-widget-open", shouldHideGoftinoByState ? "true" : "false", ); console.debug("[mori-widget] Goftino visibility synced", { hidden: shouldHideGoftinoByState, }); return () => { document.body.setAttribute("data-mori-widget-open", "false"); }; }, [isOpen]); useEffect(() => { setGoftinoLauncherHidden(isGoftinoChannelEnabled); setRaychatLauncherHidden(isRaychatChannelEnabled); setGapifyLauncherHidden(isGapifyChannelEnabled); return () => { setGoftinoLauncherHidden(false); setRaychatLauncherHidden(false); setGapifyLauncherHidden(false); }; }, [ isGoftinoChannelEnabled, isRaychatChannelEnabled, isGapifyChannelEnabled, ]); useEffect(() => { const handleOpen = (options?: { expand?: boolean }) => { setIsOpen(true); if (options?.expand) { setIsExpanded(true); } }; const handleClose = () => setIsOpen(false); const handleToggle = () => setIsOpen((prev) => !prev); const handleSendMessage = (message: string) => { const trimmedMessage = message.trim(); if (!trimmedMessage) { return; } console.log("[Mori Widget] Global sendMessage called"); handleOpen({ expand: true }); window.dispatchEvent( new CustomEvent("mori:submit-message", { detail: { message: trimmedMessage } }) ); }; const globalObj = window as typeof window & { moriWidget?: { open?: (options?: { expand?: boolean }) => void; close?: () => void; toggle?: () => void; sendMessage?: (message: string) => void; }; }; globalObj.moriWidget = { ...globalObj.moriWidget, open: handleOpen, close: handleClose, toggle: handleToggle, sendMessage: handleSendMessage }; const handleOpenEvent = withErrorHandling( "handleOpenWidgetEvent", (event: unknown) => { const detail = ( event as unknown as CustomEvent<{ expand?: boolean; }> ).detail; handleOpen(detail); }, ); const wrappedHandleClose = withErrorHandling( "handleCloseWidgetEvent", handleClose, ); const wrappedHandleToggle = withErrorHandling( "handleToggleWidgetEvent", handleToggle, ); window.addEventListener("mori:open-widget", handleOpenEvent); window.addEventListener("mori:close-widget", wrappedHandleClose); window.addEventListener("mori:toggle-widget", wrappedHandleToggle); return () => { if (globalObj.moriWidget) { if (globalObj.moriWidget.open === handleOpen) delete globalObj.moriWidget.open; if (globalObj.moriWidget.close === handleClose) delete globalObj.moriWidget.close; if (globalObj.moriWidget.toggle === handleToggle) delete globalObj.moriWidget.toggle; if (globalObj.moriWidget.sendMessage === handleSendMessage) delete globalObj.moriWidget.sendMessage; } window.removeEventListener("mori:open-widget", handleOpenEvent); window.removeEventListener("mori:close-widget", wrappedHandleClose); window.removeEventListener("mori:toggle-widget", wrappedHandleToggle); }; }, []); const isWidgetEnabled = (): boolean => { return ( botData?.is_widget_enabled === true || botData?.is_widget_enabled === null ); }; const shouldHideWidget = () => { return !isWidgetEnabled() || isCurrentPathBanned(); }; return ( {!shouldHideWidget() && ( <> setIsOpen(false)} isExpanded={isExpanded} onToggleExpand={(state: boolean | undefined) => setIsExpanded(state === undefined ? !isExpanded : state) } /> {!isOpen && ( )} )} ); } function App() { return ; } export default App;