From 00814d8558eb6481f513242461855026f0d36b8d Mon Sep 17 00:00:00 2001 From: jeseed Date: Tue, 12 Aug 2025 10:17:45 -0700 Subject: [PATCH 01/13] feat: imported actionSlice & updated imports --- components.json | 40 +- .../guidance/ActionRenderer.tsx | 899 ++++++++++++++ .../spells/QuestioningSpell.tsx | 2 +- packages/cedar-os/src/components.json | 2 +- .../components/guidance/ActionRenderer.tsx | 899 ++++++++++++++ .../guidance/components/BaseCursor.tsx | 275 +++++ .../guidance/components/CedarCursor.tsx | 574 +++++++++ .../guidance/components/ClickableArea.tsx | 242 ++++ .../guidance/components/DialogueBanner.tsx | 122 ++ .../guidance/components/DialogueBox.tsx | 252 ++++ .../guidance/components/EdgePointer.tsx | 260 ++++ .../guidance/components/ExecuteTyping.tsx | 83 ++ .../guidance/components/HighlightOverlay.tsx | 184 +++ .../guidance/components/IFActionRenderer.tsx | 601 ++++++++++ .../guidance/components/ProgressPoints.tsx | 232 ++++ .../components/RightClickIndicator.tsx | 77 ++ .../guidance/components/SurveyDialog.tsx | 553 +++++++++ .../guidance/components/TextInputPrompt.tsx | 149 +++ .../guidance/components/ToastCard.tsx | 465 ++++++++ .../guidance/components/TooltipText.tsx | 495 ++++++++ .../guidance/components/VirtualCursor.tsx | 443 +++++++ .../components/VirtualTypingCursor.tsx | 377 ++++++ .../components/guidance/components/button.tsx | 56 + .../cedar-os/src/components/guidance/index.ts | 37 + .../guidance/utils/actionEvaluationUtils.ts | 154 +++ .../guidance/utils/actionSupabase.ts | 197 ++++ .../components/guidance/utils/constants.ts | 42 + .../components/guidance/utils/elementUtils.ts | 1044 +++++++++++++++++ .../guidance/utils/positionUtils.ts | 177 +++ .../components/guidance/utils/typingUtils.ts | 373 ++++++ .../guidance/utils/viewportUtils.ts | 133 +++ .../src/store/guidance/actionsSlice.ts | 874 ++++++++++++++ src/components/ui/badge.tsx | 76 +- src/components/ui/button.tsx | 102 +- src/components/ui/card.tsx | 150 +-- src/components/ui/checkbox.tsx | 48 +- src/components/ui/dropdown-menu.tsx | 379 +++--- src/components/ui/input.tsx | 34 +- src/components/ui/label.tsx | 34 +- src/components/ui/navigation-menu.tsx | 256 ++-- src/components/ui/select.tsx | 274 +++-- src/components/ui/slider.tsx | 108 +- src/components/ui/tabs.tsx | 94 +- src/components/ui/toggle3d.tsx | 2 +- 44 files changed, 11061 insertions(+), 809 deletions(-) create mode 100644 packages/cedar-os-components/guidance/ActionRenderer.tsx create mode 100644 packages/cedar-os/src/components/guidance/ActionRenderer.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/BaseCursor.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/CedarCursor.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/ClickableArea.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/DialogueBox.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/EdgePointer.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/IFActionRenderer.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/SurveyDialog.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/TextInputPrompt.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/ToastCard.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/TooltipText.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/VirtualCursor.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/VirtualTypingCursor.tsx create mode 100644 packages/cedar-os/src/components/guidance/components/button.tsx create mode 100644 packages/cedar-os/src/components/guidance/index.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/actionEvaluationUtils.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/actionSupabase.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/constants.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/elementUtils.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/positionUtils.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/typingUtils.ts create mode 100644 packages/cedar-os/src/components/guidance/utils/viewportUtils.ts create mode 100644 packages/cedar-os/src/store/guidance/actionsSlice.ts diff --git a/components.json b/components.json index ffe928f5..c2044960 100644 --- a/components.json +++ b/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} \ No newline at end of file + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/styles/stylingUtils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/packages/cedar-os-components/guidance/ActionRenderer.tsx b/packages/cedar-os-components/guidance/ActionRenderer.tsx new file mode 100644 index 00000000..71bad4f2 --- /dev/null +++ b/packages/cedar-os-components/guidance/ActionRenderer.tsx @@ -0,0 +1,899 @@ +'use client'; + +import { getPositionFromElement } from '@/components/guidance'; +import { CedarCursor } from '@/components/guidance/components/CedarCursor'; +import DialogueBanner from '@/components/guidance/components/DialogueBanner'; +import DialogueBox from '@/components/guidance/components/DialogueBox'; +import ExecuteTyping from '@/components/guidance/components/ExecuteTyping'; +import HighlightOverlay from '@/components/guidance/components/HighlightOverlay'; +import RightClickIndicator from '@/components/guidance/components/RightClickIndicator'; +import SurveyDialog from '@/components/guidance/components/SurveyDialog'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import VirtualCursor from '@/components/guidance/components/VirtualCursor'; +import VirtualTypingCursor from '@/components/guidance/components/VirtualTypingCursor'; +import { PositionOrElement } from '@/components/guidance/utils/positionUtils'; +import ToastCard from '@/components/ToastCard'; +import { + ChatAction, + DialogueBannerAction, + GateIfAction, + VirtualClickAction, + VirtualDragAction, + VirtualTypingAction, +} from '@/store/actionsSlice'; +import { useActions, useMessages } from '@/store/CedarStore'; +import { motion } from 'framer-motion'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import IFActionRenderer from './guidance/components/IFActionRenderer'; + +// Simplified ActionRenderer that delegates IF rendering to IFActionRenderer +const ActionRenderer: React.FC = () => { + const { + currentAction, + nextAction, + isActive, + prevCursorPosition, + isAnimatingOut, + addActionsToStart, + } = useActions(); + + // Message helpers + const { addMessage } = useMessages(); + + const [actionKey, setActionKey] = useState(''); + const [currentClickIndex, setCurrentClickIndex] = useState(0); + const [dragIterationCount, setDragIterationCount] = useState(0); + const [isDragAnimatingOut, setIsDragAnimatingOut] = useState(false); + const throttleRef = useRef(false); + const executeClickTargetRef = useRef(null); + const functionAdvanceModeIntervalRef = useRef(null); + + // Call next action when animation completes + const handleActionEnd = useCallback(() => { + nextAction(currentAction?.id); + }, [currentAction, nextAction]); + + // Initialize to user's cursor position and set up tracking + useEffect(() => { + // Function to update cursor position + const updateCursorPosition = (e: MouseEvent) => { + // Skip update if we're throttling + if (throttleRef.current) return; + + // Set throttle flag + throttleRef.current = true; + + // Create position object + const position = { + x: e.clientX, + y: e.clientY, + }; + + // Store position in DOM for direct access by components + document.body.setAttribute( + 'data-cursor-position', + JSON.stringify(position) + ); + + // Clear throttle after delay + const throttleTimeout = setTimeout(() => { + throttleRef.current = false; + }, 16); // ~60fps (1000ms/60) + + return () => clearTimeout(throttleTimeout); + }; + + // Add event listener for mouse movement + document.addEventListener('mousemove', updateCursorPosition); + // Clean up event listener on component unmount + return () => { + document.removeEventListener('mousemove', updateCursorPosition); + }; + }, []); + + // When the action changes, update the key to force a complete re-render + useEffect(() => { + // Handle CHAT actions: dispatch MessageInput(s) via addMessage + if (currentAction?.type === 'CHAT') { + const chatAction = currentAction as ChatAction; + { + const runChat = async () => { + // Primary message + const delay = chatAction.messageDelay ?? 0; + if (delay > 0) { + await new Promise((res) => setTimeout(res, delay)); + } + addMessage(chatAction.content); + if (chatAction.autoAdvance !== false) { + handleActionEnd(); + } + + // Custom messages + if (chatAction.customMessages) { + for (const msg of chatAction.customMessages) { + const msgDelay = msg.messageDelay ?? 0; + if (msgDelay > 0) { + await new Promise((res) => setTimeout(res, msgDelay)); + } + addMessage(msg.content); + if (msg.autoAdvance !== false) { + handleActionEnd(); + } + } + } + }; + runChat(); + } + } + + if (currentAction?.id) { + setActionKey(currentAction.id); + setCurrentClickIndex(0); + setDragIterationCount(0); + setIsDragAnimatingOut(false); + + // Handle GATE_IF actions by evaluating the condition once and adding appropriate actions + if (currentAction.type === 'GATE_IF') { + const gateIfAction = currentAction as GateIfAction; + + // Get the condition result + const evaluateCondition = async () => { + try { + // Get initial result + const result = + typeof gateIfAction.condition === 'function' + ? gateIfAction.condition() + : gateIfAction.condition; + + let finalResult: boolean; + + if (result instanceof Promise) { + finalResult = await result; + } else { + finalResult = !!result; + } + + // Add the appropriate actions to the queue based on the result + if (finalResult) { + addActionsToStart(gateIfAction.trueActions); + } else { + addActionsToStart(gateIfAction.falseActions); + } + } catch (error) { + console.error('Error evaluating GATE_IF condition:', error); + // In case of error, add the falseActions as fallback + addActionsToStart(gateIfAction.falseActions); + } + }; + + // Start the evaluation process + evaluateCondition(); + + return; + } + + // Store target for EXECUTE_CLICK actions + if (currentAction.type === 'EXECUTE_CLICK') { + executeClickTargetRef.current = currentAction.target; + } else { + executeClickTargetRef.current = null; + } + + // Clean up any existing interval + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + + // Set up interval for function-based advanceMode + // Use proper type guards to ensure we're dealing with the right action types + if ( + currentAction.type === 'VIRTUAL_CLICK' || + currentAction.type === 'VIRTUAL_DRAG' || + currentAction.type === 'VIRTUAL_TYPING' + ) { + // Now we know it's a VirtualClickAction, VirtualDragAction, or VirtualTypingAction and has advanceMode + const clickOrDragAction = currentAction as + | VirtualClickAction + | VirtualDragAction + | VirtualTypingAction; + + if (typeof clickOrDragAction.advanceMode === 'function') { + const advanceFn = clickOrDragAction.advanceMode; + + // If the function expects at least one argument, we treat it as + // the **callback** variant – invoke once and let it call + // `nextAction` (via handleActionEnd) when ready. + if (advanceFn.length >= 1) { + (advanceFn as (next: () => void) => void)(() => { + // Ensure we don't create a new reference every call + handleActionEnd(); + }); + // No polling interval in this mode + return; + } + + // Otherwise treat it as the **predicate** variant that returns + // a boolean and should be polled until true. + + if ((advanceFn as () => boolean)()) { + // If the predicate returns true immediately, advance on next tick + setTimeout(() => handleActionEnd(), 0); + return; + } + + // Set up interval to periodically check the predicate (every 500 ms) + functionAdvanceModeIntervalRef.current = setInterval(() => { + const shouldAdvance = (advanceFn as () => boolean)(); + if (shouldAdvance) { + handleActionEnd(); + + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + } + }, 500); + } + } + } + + // Clean up interval on unmount or when currentAction changes + return () => { + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + }; + }, [ + currentAction?.id, + currentAction?.type, + handleActionEnd, + addActionsToStart, + addMessage, + nextAction, + ]); + + // Function to execute the actual click - now outside of conditional blocks + const executeClick = useCallback(() => { + // Exit if no current action or not an execute click action + if (!currentAction || currentAction.type !== 'EXECUTE_CLICK') { + return; + } + + try { + // Get the target element - properly handling lazy elements + let targetElement: PositionOrElement = currentAction.target; + + // Check if this is a lazy element and resolve it if needed + if ( + targetElement && + typeof targetElement === 'object' && + '_lazy' in targetElement && + targetElement._lazy + ) { + targetElement = targetElement.resolve() as PositionOrElement; + } + + // Check if we have a ref + if ( + targetElement && + typeof targetElement === 'object' && + 'current' in targetElement + ) { + targetElement = targetElement.current as PositionOrElement; + } + + // Handle string selectors + if (typeof targetElement === 'string') { + const element = document.querySelector(targetElement); + if (element instanceof HTMLElement) { + targetElement = element; + } + } + + // Check if we have a DOM element + if (targetElement instanceof Element) { + // First, ensure the element is in view + if (currentAction.shouldScroll !== false) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Trigger click after a short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + targetElement.dispatchEvent(clickEvent); + + // Move to the next action + handleActionEnd(); + }, 300); + } else { + // Handle case where we have coordinates instead of an element + const position = getPositionFromElement(targetElement); + if (position) { + // Find the element at the position coordinates + const elementAtPosition = document.elementFromPoint( + position.x, + position.y + ) as HTMLElement | null; + + if (elementAtPosition) { + // First, ensure the element is in view if needed + if (currentAction.shouldScroll !== false) { + elementAtPosition.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + elementAtPosition.dispatchEvent(clickEvent); + + // Move to the next action + handleActionEnd(); + }, 300); + } else { + console.error('No element found at the specified position'); + handleActionEnd(); // Proceed to next action anyway + } + } else { + console.error('Unable to execute click: Invalid target'); + handleActionEnd(); // Proceed to next action anyway + } + } + } catch (error) { + console.error('Error executing click:', error); + handleActionEnd(); // Proceed to next action anyway + } + }, [currentAction, handleActionEnd]); + + // Modified effect to handle IDLE actions with automatic duration + useEffect(() => { + if (!currentAction) return; + + if (currentAction.type === 'IDLE') { + if (currentAction.duration) { + const timeout = setTimeout(() => { + nextAction(currentAction.id); + }, currentAction.duration); + + return () => clearTimeout(timeout); + } + if (currentAction.advanceFunction) { + currentAction.advanceFunction(() => { + nextAction(currentAction.id); + }); + } + } + + // Handle auto-completing CHAT_TOOLTIP actions with duration + if (currentAction.type === 'CHAT_TOOLTIP' && currentAction.duration) { + const timeout = setTimeout(() => { + nextAction(currentAction.id); + }, currentAction.duration); + + return () => clearTimeout(timeout); + } + + // Handle EXECUTE_CLICK actions without animation - directly execute the click + if ( + currentAction.type === 'EXECUTE_CLICK' && + currentAction.showCursor === false + ) { + // Execute click directly rather than setting a state + executeClick(); + } + }, [currentAction, nextAction, executeClick, handleActionEnd]); + + // Handler for cursor animation completion + const handleCursorAnimationComplete = useCallback( + (clicked: boolean) => { + // Use type guards for different action types + if (currentAction?.type === 'VIRTUAL_CLICK') { + const clickAction = currentAction as VirtualClickAction; + if ( + clickAction.advanceMode !== 'external' && + typeof clickAction.advanceMode !== 'function' + ) { + return handleActionEnd(); + } + } + + // For VIRTUAL_DRAG with external advance mode, loop the animation + if (currentAction?.type === 'VIRTUAL_DRAG') { + // CARE -> it should default to clickable + if (clicked && currentAction.advanceMode !== 'external') { + return handleActionEnd(); + } + + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }, + [handleActionEnd, currentAction] + ); + + // Handler for MULTI_VIRTUAL_CLICK completion + const handleMultiClickComplete = useCallback(() => { + if (currentAction?.type === 'MULTI_VIRTUAL_CLICK') { + // If there are more clicks to go through + if (currentClickIndex < currentAction.actions.length - 1) { + // Move to the next click + setCurrentClickIndex((prevIndex) => prevIndex + 1); + } else if (currentAction.loop) { + // If looping is enabled, start from the beginning + setCurrentClickIndex(0); + } else if (currentAction.advanceMode !== 'external') { + // Complete the entire action only if advanceMode is not 'external' + handleActionEnd(); + } + } + }, [currentAction, currentClickIndex, handleActionEnd]); + + // Handle delay between clicks for MULTI_VIRTUAL_CLICK + useEffect(() => { + if ( + currentAction?.type === 'MULTI_VIRTUAL_CLICK' && + currentClickIndex > 0 + ) { + const defaultDelay = 500; // Default delay between clicks in ms + const delay = + currentAction.delay !== undefined ? currentAction.delay : defaultDelay; + + // Apply delay before showing the next click + const timer = setTimeout(() => { + // This empty timeout just creates a delay + }, delay); + + return () => clearTimeout(timer); + } + }, [currentAction, currentClickIndex]); + + // If there's no current action or the animation system is inactive, don't render anything + if (!isActive || !currentAction) { + return null; + } + + // Render the appropriate component based on action type + switch (currentAction.type) { + case 'IF': + return ( + + ); + case 'GATE_IF': + // GATE_IF actions are handled in the useEffect and don't need special rendering + return null; + case 'CURSOR_TAKEOVER': + return ( + + ); + + case 'VIRTUAL_CLICK': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + // Determine the advanceMode from the action – if the provided value + // is a **callback** variant (expects an argument), fall back to + // 'default' as VirtualCursor itself doesn't need to know about it. + const rawAdvanceMode = currentAction.advanceMode; + type CursorAdvanceMode = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceMode = + typeof rawAdvanceMode === 'function' && + ((rawAdvanceMode as (...args: unknown[]) => unknown).length ?? 0) >= 1 + ? 'default' + : (rawAdvanceMode as CursorAdvanceMode) || 'default'; + + return ( + + + + ); + } + + case 'VIRTUAL_DRAG': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + case 'MULTI_VIRTUAL_CLICK': { + // Determine the current click action + const currentClickAction = currentAction.actions[currentClickIndex]; + + // Determine the start position based on the click index + let startPosition: PositionOrElement | undefined; + if (currentClickIndex === 0) { + // For the first click, use the previous cursor position or the specified start position + startPosition = + currentClickAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + } else { + // For subsequent clicks, always use their specified start position if available, + // otherwise fallback to the end position of the previous click + startPosition = + currentClickAction.startPosition || + currentAction.actions[currentClickIndex - 1].endPosition; + } + + // Use the same advanceMode calculation as for VIRTUAL_CLICK + const rawAdvanceModeMulti = currentClickAction.advanceMode; + type CursorAdvanceModeMulti = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceModeMulti = + typeof rawAdvanceModeMulti === 'function' && + ((rawAdvanceModeMulti as (...args: unknown[]) => unknown).length ?? + 0) >= 1 + ? 'default' + : (rawAdvanceModeMulti as CursorAdvanceModeMulti) || 'default'; + + return ( + + ); + } + + case 'VIRTUAL_TYPING': { + // Determine the start position - use action.startPosition if provided, + const typingStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + { + if (typeof currentAction.advanceMode === 'function') { + return 'default'; + } + return ( + (currentAction.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | undefined) || 'default' + ); + })()} + onAnimationComplete={handleActionEnd} + blocking={currentAction.blocking} + /> + ); + } + + case 'CHAT_TOOLTIP': { + // Find the chat button to position the tooltip + const chatButton = + document.querySelector('.CedarChatButton') || + document.querySelector('[data-cedar-chat-button]'); + const chatButtonRect = chatButton?.getBoundingClientRect(); + + if (!chatButtonRect) { + // If chat button not found, complete this action and go to next + setTimeout(handleActionEnd, 100); + return null; + } + + // Calculate centered position above chat button + // We know TooltipText with position="top" will apply translateY(-100%) + // So we place this at the center top of the button + const tooltipPosition = { + left: chatButtonRect.left + chatButtonRect.width / 2, + top: chatButtonRect.top - 15, // Add a small vertical offset + }; + + return ( + + handleActionEnd()} + /> + + ); + } + + case 'CHAT': + return null; + + case 'IDLE': + return null; + + case 'DIALOGUE': + return ( + <> + {currentAction.highlightElements && ( + + )} + boolean) => { + if ( + typeof currentAction.advanceMode === 'function' && + ((currentAction.advanceMode as (...args: unknown[]) => unknown) + .length ?? 0) >= 1 + ) { + return 'default'; + } + return ( + (currentAction.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean)) || 'default' + ); + })()} + blocking={currentAction.blocking} + onComplete={handleActionEnd} + /> + + ); + + case 'DIALOGUE_BANNER': { + const bannerAction = currentAction as DialogueBannerAction & { + children?: React.ReactNode; + }; + return ( + + {bannerAction.children ?? bannerAction.text} + + ); + } + + case 'SURVEY': + return ( + { + if (!open && !currentAction.blocking) { + handleActionEnd(); + } + }} + submitButtonText={currentAction.submitButtonText} + cancelButtonText={currentAction.cancelButtonText} + onSubmit={(responses) => { + currentAction.onSubmit?.(responses); + handleActionEnd(); + }} + blocking={currentAction.blocking} + trigger_id={currentAction.trigger_id} + /> + ); + + case 'EXECUTE_CLICK': { + // Only render cursor animation if showCursor is true (default) or undefined + const showCursor = currentAction.showCursor !== false; + + if (showCursor) { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + // No visual component needed as the executeClick will be triggered by the useEffect + return null; + } + + case 'TOAST': { + return ( + nextAction(currentAction.id)} + /> + ); + } + + case 'EXECUTE_TYPING': { + return ( + + ); + } + + case 'RIGHT_CLICK': { + return ( + + ); + } + + default: + console.error('Unknown action type:', currentAction); + return null; + } +}; + +export default ActionRenderer; diff --git a/packages/cedar-os-components/spells/QuestioningSpell.tsx b/packages/cedar-os-components/spells/QuestioningSpell.tsx index 2077d630..a8b7ec8e 100644 --- a/packages/cedar-os-components/spells/QuestioningSpell.tsx +++ b/packages/cedar-os-components/spells/QuestioningSpell.tsx @@ -11,7 +11,7 @@ import { type ActivationConditions, } from 'cedar-os'; // TODO: TooltipText component needs to be created -// import TooltipText from '@/components/interactions/components/TooltipText'; +// import TooltipText from '@/components/guidance/components/TooltipText'; interface QuestioningSpellProps { /** Unique identifier for this spell instance */ diff --git a/packages/cedar-os/src/components.json b/packages/cedar-os/src/components.json index 1589d2ea..f1a5bb59 100644 --- a/packages/cedar-os/src/components.json +++ b/packages/cedar-os/src/components.json @@ -12,7 +12,7 @@ }, "aliases": { "components": "@/components", - "utils": "@/lib/utils", + "utils": "@/styles/stylingUtils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" diff --git a/packages/cedar-os/src/components/guidance/ActionRenderer.tsx b/packages/cedar-os/src/components/guidance/ActionRenderer.tsx new file mode 100644 index 00000000..71bad4f2 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/ActionRenderer.tsx @@ -0,0 +1,899 @@ +'use client'; + +import { getPositionFromElement } from '@/components/guidance'; +import { CedarCursor } from '@/components/guidance/components/CedarCursor'; +import DialogueBanner from '@/components/guidance/components/DialogueBanner'; +import DialogueBox from '@/components/guidance/components/DialogueBox'; +import ExecuteTyping from '@/components/guidance/components/ExecuteTyping'; +import HighlightOverlay from '@/components/guidance/components/HighlightOverlay'; +import RightClickIndicator from '@/components/guidance/components/RightClickIndicator'; +import SurveyDialog from '@/components/guidance/components/SurveyDialog'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import VirtualCursor from '@/components/guidance/components/VirtualCursor'; +import VirtualTypingCursor from '@/components/guidance/components/VirtualTypingCursor'; +import { PositionOrElement } from '@/components/guidance/utils/positionUtils'; +import ToastCard from '@/components/ToastCard'; +import { + ChatAction, + DialogueBannerAction, + GateIfAction, + VirtualClickAction, + VirtualDragAction, + VirtualTypingAction, +} from '@/store/actionsSlice'; +import { useActions, useMessages } from '@/store/CedarStore'; +import { motion } from 'framer-motion'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import IFActionRenderer from './guidance/components/IFActionRenderer'; + +// Simplified ActionRenderer that delegates IF rendering to IFActionRenderer +const ActionRenderer: React.FC = () => { + const { + currentAction, + nextAction, + isActive, + prevCursorPosition, + isAnimatingOut, + addActionsToStart, + } = useActions(); + + // Message helpers + const { addMessage } = useMessages(); + + const [actionKey, setActionKey] = useState(''); + const [currentClickIndex, setCurrentClickIndex] = useState(0); + const [dragIterationCount, setDragIterationCount] = useState(0); + const [isDragAnimatingOut, setIsDragAnimatingOut] = useState(false); + const throttleRef = useRef(false); + const executeClickTargetRef = useRef(null); + const functionAdvanceModeIntervalRef = useRef(null); + + // Call next action when animation completes + const handleActionEnd = useCallback(() => { + nextAction(currentAction?.id); + }, [currentAction, nextAction]); + + // Initialize to user's cursor position and set up tracking + useEffect(() => { + // Function to update cursor position + const updateCursorPosition = (e: MouseEvent) => { + // Skip update if we're throttling + if (throttleRef.current) return; + + // Set throttle flag + throttleRef.current = true; + + // Create position object + const position = { + x: e.clientX, + y: e.clientY, + }; + + // Store position in DOM for direct access by components + document.body.setAttribute( + 'data-cursor-position', + JSON.stringify(position) + ); + + // Clear throttle after delay + const throttleTimeout = setTimeout(() => { + throttleRef.current = false; + }, 16); // ~60fps (1000ms/60) + + return () => clearTimeout(throttleTimeout); + }; + + // Add event listener for mouse movement + document.addEventListener('mousemove', updateCursorPosition); + // Clean up event listener on component unmount + return () => { + document.removeEventListener('mousemove', updateCursorPosition); + }; + }, []); + + // When the action changes, update the key to force a complete re-render + useEffect(() => { + // Handle CHAT actions: dispatch MessageInput(s) via addMessage + if (currentAction?.type === 'CHAT') { + const chatAction = currentAction as ChatAction; + { + const runChat = async () => { + // Primary message + const delay = chatAction.messageDelay ?? 0; + if (delay > 0) { + await new Promise((res) => setTimeout(res, delay)); + } + addMessage(chatAction.content); + if (chatAction.autoAdvance !== false) { + handleActionEnd(); + } + + // Custom messages + if (chatAction.customMessages) { + for (const msg of chatAction.customMessages) { + const msgDelay = msg.messageDelay ?? 0; + if (msgDelay > 0) { + await new Promise((res) => setTimeout(res, msgDelay)); + } + addMessage(msg.content); + if (msg.autoAdvance !== false) { + handleActionEnd(); + } + } + } + }; + runChat(); + } + } + + if (currentAction?.id) { + setActionKey(currentAction.id); + setCurrentClickIndex(0); + setDragIterationCount(0); + setIsDragAnimatingOut(false); + + // Handle GATE_IF actions by evaluating the condition once and adding appropriate actions + if (currentAction.type === 'GATE_IF') { + const gateIfAction = currentAction as GateIfAction; + + // Get the condition result + const evaluateCondition = async () => { + try { + // Get initial result + const result = + typeof gateIfAction.condition === 'function' + ? gateIfAction.condition() + : gateIfAction.condition; + + let finalResult: boolean; + + if (result instanceof Promise) { + finalResult = await result; + } else { + finalResult = !!result; + } + + // Add the appropriate actions to the queue based on the result + if (finalResult) { + addActionsToStart(gateIfAction.trueActions); + } else { + addActionsToStart(gateIfAction.falseActions); + } + } catch (error) { + console.error('Error evaluating GATE_IF condition:', error); + // In case of error, add the falseActions as fallback + addActionsToStart(gateIfAction.falseActions); + } + }; + + // Start the evaluation process + evaluateCondition(); + + return; + } + + // Store target for EXECUTE_CLICK actions + if (currentAction.type === 'EXECUTE_CLICK') { + executeClickTargetRef.current = currentAction.target; + } else { + executeClickTargetRef.current = null; + } + + // Clean up any existing interval + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + + // Set up interval for function-based advanceMode + // Use proper type guards to ensure we're dealing with the right action types + if ( + currentAction.type === 'VIRTUAL_CLICK' || + currentAction.type === 'VIRTUAL_DRAG' || + currentAction.type === 'VIRTUAL_TYPING' + ) { + // Now we know it's a VirtualClickAction, VirtualDragAction, or VirtualTypingAction and has advanceMode + const clickOrDragAction = currentAction as + | VirtualClickAction + | VirtualDragAction + | VirtualTypingAction; + + if (typeof clickOrDragAction.advanceMode === 'function') { + const advanceFn = clickOrDragAction.advanceMode; + + // If the function expects at least one argument, we treat it as + // the **callback** variant – invoke once and let it call + // `nextAction` (via handleActionEnd) when ready. + if (advanceFn.length >= 1) { + (advanceFn as (next: () => void) => void)(() => { + // Ensure we don't create a new reference every call + handleActionEnd(); + }); + // No polling interval in this mode + return; + } + + // Otherwise treat it as the **predicate** variant that returns + // a boolean and should be polled until true. + + if ((advanceFn as () => boolean)()) { + // If the predicate returns true immediately, advance on next tick + setTimeout(() => handleActionEnd(), 0); + return; + } + + // Set up interval to periodically check the predicate (every 500 ms) + functionAdvanceModeIntervalRef.current = setInterval(() => { + const shouldAdvance = (advanceFn as () => boolean)(); + if (shouldAdvance) { + handleActionEnd(); + + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + } + }, 500); + } + } + } + + // Clean up interval on unmount or when currentAction changes + return () => { + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + }; + }, [ + currentAction?.id, + currentAction?.type, + handleActionEnd, + addActionsToStart, + addMessage, + nextAction, + ]); + + // Function to execute the actual click - now outside of conditional blocks + const executeClick = useCallback(() => { + // Exit if no current action or not an execute click action + if (!currentAction || currentAction.type !== 'EXECUTE_CLICK') { + return; + } + + try { + // Get the target element - properly handling lazy elements + let targetElement: PositionOrElement = currentAction.target; + + // Check if this is a lazy element and resolve it if needed + if ( + targetElement && + typeof targetElement === 'object' && + '_lazy' in targetElement && + targetElement._lazy + ) { + targetElement = targetElement.resolve() as PositionOrElement; + } + + // Check if we have a ref + if ( + targetElement && + typeof targetElement === 'object' && + 'current' in targetElement + ) { + targetElement = targetElement.current as PositionOrElement; + } + + // Handle string selectors + if (typeof targetElement === 'string') { + const element = document.querySelector(targetElement); + if (element instanceof HTMLElement) { + targetElement = element; + } + } + + // Check if we have a DOM element + if (targetElement instanceof Element) { + // First, ensure the element is in view + if (currentAction.shouldScroll !== false) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Trigger click after a short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + targetElement.dispatchEvent(clickEvent); + + // Move to the next action + handleActionEnd(); + }, 300); + } else { + // Handle case where we have coordinates instead of an element + const position = getPositionFromElement(targetElement); + if (position) { + // Find the element at the position coordinates + const elementAtPosition = document.elementFromPoint( + position.x, + position.y + ) as HTMLElement | null; + + if (elementAtPosition) { + // First, ensure the element is in view if needed + if (currentAction.shouldScroll !== false) { + elementAtPosition.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + elementAtPosition.dispatchEvent(clickEvent); + + // Move to the next action + handleActionEnd(); + }, 300); + } else { + console.error('No element found at the specified position'); + handleActionEnd(); // Proceed to next action anyway + } + } else { + console.error('Unable to execute click: Invalid target'); + handleActionEnd(); // Proceed to next action anyway + } + } + } catch (error) { + console.error('Error executing click:', error); + handleActionEnd(); // Proceed to next action anyway + } + }, [currentAction, handleActionEnd]); + + // Modified effect to handle IDLE actions with automatic duration + useEffect(() => { + if (!currentAction) return; + + if (currentAction.type === 'IDLE') { + if (currentAction.duration) { + const timeout = setTimeout(() => { + nextAction(currentAction.id); + }, currentAction.duration); + + return () => clearTimeout(timeout); + } + if (currentAction.advanceFunction) { + currentAction.advanceFunction(() => { + nextAction(currentAction.id); + }); + } + } + + // Handle auto-completing CHAT_TOOLTIP actions with duration + if (currentAction.type === 'CHAT_TOOLTIP' && currentAction.duration) { + const timeout = setTimeout(() => { + nextAction(currentAction.id); + }, currentAction.duration); + + return () => clearTimeout(timeout); + } + + // Handle EXECUTE_CLICK actions without animation - directly execute the click + if ( + currentAction.type === 'EXECUTE_CLICK' && + currentAction.showCursor === false + ) { + // Execute click directly rather than setting a state + executeClick(); + } + }, [currentAction, nextAction, executeClick, handleActionEnd]); + + // Handler for cursor animation completion + const handleCursorAnimationComplete = useCallback( + (clicked: boolean) => { + // Use type guards for different action types + if (currentAction?.type === 'VIRTUAL_CLICK') { + const clickAction = currentAction as VirtualClickAction; + if ( + clickAction.advanceMode !== 'external' && + typeof clickAction.advanceMode !== 'function' + ) { + return handleActionEnd(); + } + } + + // For VIRTUAL_DRAG with external advance mode, loop the animation + if (currentAction?.type === 'VIRTUAL_DRAG') { + // CARE -> it should default to clickable + if (clicked && currentAction.advanceMode !== 'external') { + return handleActionEnd(); + } + + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }, + [handleActionEnd, currentAction] + ); + + // Handler for MULTI_VIRTUAL_CLICK completion + const handleMultiClickComplete = useCallback(() => { + if (currentAction?.type === 'MULTI_VIRTUAL_CLICK') { + // If there are more clicks to go through + if (currentClickIndex < currentAction.actions.length - 1) { + // Move to the next click + setCurrentClickIndex((prevIndex) => prevIndex + 1); + } else if (currentAction.loop) { + // If looping is enabled, start from the beginning + setCurrentClickIndex(0); + } else if (currentAction.advanceMode !== 'external') { + // Complete the entire action only if advanceMode is not 'external' + handleActionEnd(); + } + } + }, [currentAction, currentClickIndex, handleActionEnd]); + + // Handle delay between clicks for MULTI_VIRTUAL_CLICK + useEffect(() => { + if ( + currentAction?.type === 'MULTI_VIRTUAL_CLICK' && + currentClickIndex > 0 + ) { + const defaultDelay = 500; // Default delay between clicks in ms + const delay = + currentAction.delay !== undefined ? currentAction.delay : defaultDelay; + + // Apply delay before showing the next click + const timer = setTimeout(() => { + // This empty timeout just creates a delay + }, delay); + + return () => clearTimeout(timer); + } + }, [currentAction, currentClickIndex]); + + // If there's no current action or the animation system is inactive, don't render anything + if (!isActive || !currentAction) { + return null; + } + + // Render the appropriate component based on action type + switch (currentAction.type) { + case 'IF': + return ( + + ); + case 'GATE_IF': + // GATE_IF actions are handled in the useEffect and don't need special rendering + return null; + case 'CURSOR_TAKEOVER': + return ( + + ); + + case 'VIRTUAL_CLICK': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + // Determine the advanceMode from the action – if the provided value + // is a **callback** variant (expects an argument), fall back to + // 'default' as VirtualCursor itself doesn't need to know about it. + const rawAdvanceMode = currentAction.advanceMode; + type CursorAdvanceMode = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceMode = + typeof rawAdvanceMode === 'function' && + ((rawAdvanceMode as (...args: unknown[]) => unknown).length ?? 0) >= 1 + ? 'default' + : (rawAdvanceMode as CursorAdvanceMode) || 'default'; + + return ( + + + + ); + } + + case 'VIRTUAL_DRAG': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + case 'MULTI_VIRTUAL_CLICK': { + // Determine the current click action + const currentClickAction = currentAction.actions[currentClickIndex]; + + // Determine the start position based on the click index + let startPosition: PositionOrElement | undefined; + if (currentClickIndex === 0) { + // For the first click, use the previous cursor position or the specified start position + startPosition = + currentClickAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + } else { + // For subsequent clicks, always use their specified start position if available, + // otherwise fallback to the end position of the previous click + startPosition = + currentClickAction.startPosition || + currentAction.actions[currentClickIndex - 1].endPosition; + } + + // Use the same advanceMode calculation as for VIRTUAL_CLICK + const rawAdvanceModeMulti = currentClickAction.advanceMode; + type CursorAdvanceModeMulti = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceModeMulti = + typeof rawAdvanceModeMulti === 'function' && + ((rawAdvanceModeMulti as (...args: unknown[]) => unknown).length ?? + 0) >= 1 + ? 'default' + : (rawAdvanceModeMulti as CursorAdvanceModeMulti) || 'default'; + + return ( + + ); + } + + case 'VIRTUAL_TYPING': { + // Determine the start position - use action.startPosition if provided, + const typingStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + { + if (typeof currentAction.advanceMode === 'function') { + return 'default'; + } + return ( + (currentAction.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | undefined) || 'default' + ); + })()} + onAnimationComplete={handleActionEnd} + blocking={currentAction.blocking} + /> + ); + } + + case 'CHAT_TOOLTIP': { + // Find the chat button to position the tooltip + const chatButton = + document.querySelector('.CedarChatButton') || + document.querySelector('[data-cedar-chat-button]'); + const chatButtonRect = chatButton?.getBoundingClientRect(); + + if (!chatButtonRect) { + // If chat button not found, complete this action and go to next + setTimeout(handleActionEnd, 100); + return null; + } + + // Calculate centered position above chat button + // We know TooltipText with position="top" will apply translateY(-100%) + // So we place this at the center top of the button + const tooltipPosition = { + left: chatButtonRect.left + chatButtonRect.width / 2, + top: chatButtonRect.top - 15, // Add a small vertical offset + }; + + return ( + + handleActionEnd()} + /> + + ); + } + + case 'CHAT': + return null; + + case 'IDLE': + return null; + + case 'DIALOGUE': + return ( + <> + {currentAction.highlightElements && ( + + )} + boolean) => { + if ( + typeof currentAction.advanceMode === 'function' && + ((currentAction.advanceMode as (...args: unknown[]) => unknown) + .length ?? 0) >= 1 + ) { + return 'default'; + } + return ( + (currentAction.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean)) || 'default' + ); + })()} + blocking={currentAction.blocking} + onComplete={handleActionEnd} + /> + + ); + + case 'DIALOGUE_BANNER': { + const bannerAction = currentAction as DialogueBannerAction & { + children?: React.ReactNode; + }; + return ( + + {bannerAction.children ?? bannerAction.text} + + ); + } + + case 'SURVEY': + return ( + { + if (!open && !currentAction.blocking) { + handleActionEnd(); + } + }} + submitButtonText={currentAction.submitButtonText} + cancelButtonText={currentAction.cancelButtonText} + onSubmit={(responses) => { + currentAction.onSubmit?.(responses); + handleActionEnd(); + }} + blocking={currentAction.blocking} + trigger_id={currentAction.trigger_id} + /> + ); + + case 'EXECUTE_CLICK': { + // Only render cursor animation if showCursor is true (default) or undefined + const showCursor = currentAction.showCursor !== false; + + if (showCursor) { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + // No visual component needed as the executeClick will be triggered by the useEffect + return null; + } + + case 'TOAST': { + return ( + nextAction(currentAction.id)} + /> + ); + } + + case 'EXECUTE_TYPING': { + return ( + + ); + } + + case 'RIGHT_CLICK': { + return ( + + ); + } + + default: + console.error('Unknown action type:', currentAction); + return null; + } +}; + +export default ActionRenderer; diff --git a/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx b/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx new file mode 100644 index 00000000..77a4a9da --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx @@ -0,0 +1,275 @@ +import React, { ReactNode } from 'react'; +import { motion } from 'framer-motion'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import { useStyling } from '@/store/CedarStore'; +import { Position } from '@/components/guidance/utils/positionUtils'; + +// Define Timeout type +export type Timeout = ReturnType; + +// Calculate best tooltip position based on viewport visibility using screen quadrants +export const calculateBestTooltipPosition = ( + position: Position, + tooltipContent: string | undefined, + positionOverride?: 'left' | 'right' | 'top' | 'bottom' +): 'left' | 'right' | 'top' | 'bottom' => { + // If preferred position is provided, use it + if (positionOverride) { + return positionOverride; + } + + // If no content, default to right + if (!tooltipContent) { + return 'right'; + } + + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Determine screen center points + const centerX = viewportWidth / 2; + const centerY = viewportHeight / 2; + + // Determine which quadrant the cursor is in + const isRight = position.x >= centerX; + const isBottom = position.y >= centerY; + + // Choose position based on quadrant to keep tooltip on screen + if (isRight) { + // Right side of screen - prefer left tooltip + if (isBottom) { + // Bottom-right quadrant - use left or top + // For elements near bottom, prioritize top + if (position.y > viewportHeight - 150) { + return 'top'; + } + return 'left'; + } else { + // Top-right quadrant - use left or bottom + return 'left'; + } + } else { + // Left side of screen - prefer right tooltip + if (isBottom) { + // Bottom-left quadrant - use right or top + // For elements near bottom, prioritize top + if (position.y > viewportHeight - 150) { + return 'top'; + } + return 'right'; + } else { + // Top-left quadrant - use right or bottom + return 'right'; + } + } +}; + +interface BaseCursorProps { + startPosition: Position; + endPosition: Position; + fadeOut: boolean; + onAnimationComplete: () => void; + children?: ReactNode; + tooltipText?: string; + tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; + tooltipAnchor?: 'rect' | 'cursor'; // Whether to anchor tooltip to rect or cursor + showTooltip?: boolean; + onTooltipComplete: () => void; + cursorKey?: string; + endRect?: DOMRect | null; + dragCursor?: boolean; // Whether to use the drag cursor animation +} + +const BaseCursor: React.FC = ({ + startPosition, + endPosition, + fadeOut, + onAnimationComplete, + children, + tooltipText, + tooltipPosition = 'bottom', + tooltipAnchor = 'rect', + showTooltip = false, + onTooltipComplete, + cursorKey = 'base-cursor', + endRect = null, + dragCursor = false, +}) => { + const { styling } = useStyling(); + const [dragState, setDragState] = React.useState( + dragCursor ? 'closed' : 'normal' + ); + const animationCompleteCallback = React.useRef(onAnimationComplete); + + React.useEffect(() => { + animationCompleteCallback.current = onAnimationComplete; + }, [onAnimationComplete]); + + // Determine animation config based on dragCursor + const animationConfig = React.useMemo(() => { + if (dragCursor) { + // Slower, ease-in-out animation for drag operations + return { + type: 'tween' as const, + duration: 1, // Duration for the movement + ease: 'easeInOut', // Smooth ease-in-out curve + delay: 0.3, // Delay before starting movement + }; + } + // Default spring animation for standard cursor movements + return { + type: 'spring' as const, + stiffness: 50, + damping: 17, + mass: 1.8, + }; + }, [dragCursor]); + + // Handle animation states for drag cursor + React.useEffect(() => { + if (dragCursor) { + // Start with open hand + setDragState('open'); + + // After 0.3s delay, close the hand to start the drag + const closeHandTimeout = setTimeout(() => { + setDragState('closed'); + }, 300); + + return () => clearTimeout(closeHandTimeout); + } + }, [dragCursor]); + + // Handle the completion of the movement animation + const handleMovementComplete = () => { + if (dragCursor) { + // Open hand immediately after movement completes + setDragState('open'); + + // After 0.3s delay, call the onAnimationComplete callback + const completeTimeout = setTimeout(() => { + animationCompleteCallback.current(); + }, 300); + + return () => clearTimeout(completeTimeout); + } else { + // For non-drag animations, call the callback immediately + onAnimationComplete(); + } + }; + + // Render different cursor SVGs based on dragState + const renderCursor = () => { + if (!dragCursor || dragState === 'normal') { + // Default pointer cursor + return ( + + + + ); + } else if (dragState === 'closed') { + return ( + + + + + ); + } else if (dragState === 'open') { + return ( + + + + + ); + } + }; + + return ( + + {renderCursor()} + + {tooltipText && showTooltip ? ( + { + onTooltipComplete(); + }} + fadeOut={fadeOut} + endRect={endRect} + /> + ) : ( + children + )} + + ); +}; + +export default BaseCursor; diff --git a/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx b/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx new file mode 100644 index 00000000..7fef4162 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx @@ -0,0 +1,574 @@ +'use client'; + +import gsap from 'gsap'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { motion } from 'motion/react'; +import { useStyling } from '@/store/CedarStore'; + +interface CedarCursorProps { + isRedirected: boolean; + messages?: string[]; // Add support for custom messages + onAnimationComplete?: () => void; + cursorColor?: string; // Add support for custom cursor color + blocking?: boolean; // Add blocking overlay support +} + +export function CedarCursor({ + isRedirected, + messages = ['Oh...', 'Is this an investor I see?', 'Enter Secret Demo'], // Default messages + onAnimationComplete, + cursorColor = '#FFBFE9', // Default color - pink + blocking = false, // Default to non-blocking +}: CedarCursorProps) { + const cursorRef = useRef(null); + const textRef = useRef(null); + const particlesRef = useRef(null); + const cursorCtx = useRef(null); + const [isAnimationComplete, setIsAnimationComplete] = useState(false); + const [fadeOut, setFadeOut] = useState(false); + // const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 }); + + // --------------------------------------------------------------------- + // Tooltip-like styling (reuse values from global styling context) + // --------------------------------------------------------------------- + const { styling } = useStyling(); + + // Derive styling values mimicking TooltipText.tsx + const bgColor = styling?.color || cursorColor; + const textColor = styling?.textColor || '#FFFFFF'; + const tooltipStyle = styling?.tooltipStyle || 'solid'; + const tooltipFontSize = styling?.tooltipSize || 'sm'; + + const fontSizeClassMap: Record = { + xs: 'text-xs', + sm: 'text-sm', + base: 'text-base', + lg: 'text-lg', + xl: 'text-xl', + '2xl': 'text-2xl', + }; + const fontSizeClass = fontSizeClassMap[tooltipFontSize] ?? 'text-sm'; + + const boxShadowValue = + tooltipStyle === 'lined' + ? `0 0 8px 2px ${bgColor}80, 0 4px 6px -1px rgba(0,0,0,0.1)` + : `0 0 2px 1px ${bgColor}30, 0 2px 4px -1px rgba(0,0,0,0.1)`; + + const tooltipBg = tooltipStyle === 'lined' ? 'white' : bgColor; + const tooltipBorderColor = tooltipStyle === 'lined' ? bgColor : 'white'; + + // Base style applied to the floating text element + const baseTextStyle: React.CSSProperties = { + opacity: 0, + transform: 'translate(-50%, -100%)', + willChange: 'transform', + position: 'fixed', + zIndex: 2147483647, + backgroundColor: tooltipBg, + color: textColor, + borderColor: tooltipBorderColor, + boxShadow: boxShadowValue, + padding: '6px 10px', + borderRadius: '9999px', + }; + + // Calculate duration based on message length + const calculateDuration = (message: string): number => { + // Based on reading speed: ~30 characters per second + // Minimum duration from 0.5s + const baseTime = 0.5; + const charTime = message.length / 30; + return Math.max(baseTime, charTime); + }; + + // Generate random size for the cursor + const getRandomSize = (): number => { + // Random number between 1.8 and 2.8 for more constrained size variation + return 1.8 + Math.random() * 1.0; + }; + + // Get a subtle size change (smaller variance) + const getSubtleChange = (currentSize: number): number => { + // Add or subtract a smaller amount (0.2 to 0.4) from current size for more subtle changes + const change = 0.2 + Math.random() * 0.2; + // 50% chance to grow or shrink + return Math.random() > 0.5 + ? Math.min(currentSize + change, 2.8) + : Math.max(currentSize - change, 1.8); + }; + + // // Create a "talking" animation sequence that simulates speech with size variations + // const createTalkingAnimation = ( + // timeline: gsap.core.Timeline, + // cursor: HTMLElement, + // message: string, + // baseSize: number // Base size parameter to maintain expanded size + // ) => { + // // Calculate syllables for a more natural speech pattern + // const wordCount = message.split(' ').length; + // const characterCount = message.replace(/\s/g, '').length; + // // Estimate syllables - roughly one per 2-3 characters plus extra for longer words + // const syllableEstimate = Math.max(characterCount / 2.5, wordCount * 2); + // const movements = Math.ceil(syllableEstimate); // At least one movement per estimated syllable + + // // Create a sequence of size changes to simulate natural speech + // for (let i = 0; i < movements; i++) { + // // Determine if this is a primary (larger) or secondary (smaller) syllable + // const isPrimarySyllable = i % 3 === 0 || Math.random() > 0.6; // Every 3rd syllable or 40% random chance + + // // Create varied size increases - larger for primary syllables, smaller for secondary + // const sizeIncrease = isPrimarySyllable + // ? baseSize * (1.15 + Math.random() * 0.15) // Primary: 15-30% larger (1.15-1.30) + // : baseSize * (1.05 + Math.random() * 0.08); // Secondary: 5-13% larger (1.05-1.13) + + // // Add a "talking" animation that preserves the circular shape + // timeline + // // Expand to simulate "speaking" + // .to(cursor, { + // scale: sizeIncrease, // Uniform scaling to preserve circle shape + // duration: 0.15 + Math.random() * 0.1, // Varied expansion speed (0.15-0.25s) + // ease: 'power2.out', // Slightly accelerating out + // }) + // // Contract back to near the base size + // .to(cursor, { + // scale: baseSize * (0.95 + Math.random() * 0.05), // Slightly under baseSize (0.95-1.0) + // duration: 0.1 + Math.random() * 0.1, // Varied contraction speed (0.1-0.2s) + // ease: 'power1.in', // Slightly accelerating in + // }); + + // // Add varied pauses between size changes + // if (i < movements - 1) { + // // Determine if we need a brief pause (syllable) or longer pause (word break) + // const isWordBreak = Math.random() > 0.7; // About 30% chance of a word break + + // timeline.to( + // {}, + // { + // duration: isWordBreak + // ? 0.2 + Math.random() * 0.2 // Word break: 0.2-0.4s pause + // : 0.05 + Math.random() * 0.1, // Syllable: 0.05-0.15s pause + // } + // ); + // } + // } + + // // Final restoration to exactly baseSize + // timeline.to(cursor, { + // scale: baseSize, + // duration: 0.15, + // ease: 'power1.out', + // }); + + // return timeline; + // }; + + // Handle cursor movement + useEffect(() => { + const cursor = cursorRef.current; + const text = textRef.current; + if (!cursor || !text) return; + + const onMouseMove = (e: MouseEvent) => { + // Store cursor position for particles + // setCursorPosition({ x: e.clientX, y: e.clientY }); + + // Apply position directly to match exact cursor position + cursor.style.left = `${e.clientX}px`; + cursor.style.top = `${e.clientY}px`; + + // Position the text tooltip directly above the cursor with direct CSS positioning + // Center it horizontally and position it above the cursor + text.style.left = `${e.clientX}px`; + text.style.top = `${e.clientY - 40}px`; // Increased from 40px to 50px for better spacing + }; + + document.body.style.cursor = 'none'; + + window.addEventListener('mousemove', onMouseMove); + return () => { + window.removeEventListener('mousemove', onMouseMove); + document.body.style.cursor = 'auto'; + }; + }, []); + + // Handle redirect animation sequence + useEffect(() => { + const cursor = cursorRef.current; + const text = textRef.current; + if (!cursor || !text) return; + + cursorCtx.current?.revert(); + cursorCtx.current = gsap.context(() => { + const tl = gsap.timeline({ + onComplete: () => { + setIsAnimationComplete(true); + setFadeOut(true); + // Fix for single message issue: If there's just one message, + // ensure we call onAnimationComplete to avoid getting stuck + if (messages.length === 1 && onAnimationComplete) { + onAnimationComplete(); + } + }, + }); + + // Initial pause - now 2 seconds + tl.to({}, { duration: 1.5 }); + + // Track cursor size to allow for relative changes + let currentCursorSize = 1; + + // Create animation sequence for each message + messages.forEach((message, index) => { + // Calculate message display duration based on length + const messageDuration = calculateDuration(message); + // Random size for initial cursor growth + const initialSize = getRandomSize(); + currentCursorSize = initialSize; + + // For first message + if (index === 0) { + tl.to(cursor, { + scale: initialSize, + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + .to(text, { + opacity: 1, + scale: 1, + duration: 0.5, + padding: '4px 8px', + borderRadius: '9999px', + ease: 'power2.out', + onComplete: () => { + text.style.setProperty('opacity', '1', 'important'); + }, + }); + + // Create a nested timeline for talking animation + const talkingTl = gsap.timeline(); + // Pass the initialSize to maintain the expanded cursor + // createTalkingAnimation(talkingTl, cursor, message, initialSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 1-2 subtle size changes during text display (reduced from 2-3) + const numChanges = 1 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining pause time + tl.to({}, { duration: changeInterval }); + } + // For middle messages - interleave cursor and text animations + else if (index < messages.length - 1) { + // Fade out previous text + tl.to(text, { + opacity: 0, + scale: 0.8, + duration: 0.3, + ease: 'power2.in', + }) + // Animate cursor between messages (with varied size change) + .to(cursor, { + scale: 1.8 + Math.random() * 0.4, // More constrained transition size + duration: 0.3, + ease: 'power2.in', + }) + // Set new message text while it's invisible + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + // Scale up cursor with elastic effect (to random size) + .to(cursor, { + scale: initialSize, + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + // Bring in the new text + .to(text, { + opacity: 1, + scale: 1.2, + duration: 0.5, + padding: '4px 8px', + borderRadius: '9999px', + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + ease: 'power2.out', + }); + + // Create a nested timeline for talking animation + const talkingTl = gsap.timeline(); + // Pass the initialSize to maintain the expanded cursor + // createTalkingAnimation(talkingTl, cursor, message, initialSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 1-2 subtle size changes during text display (reduced from 2-4) + const numChanges = 1 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining pause time + tl.to({}, { duration: changeInterval }); + } + // For the last message (transform to button) + else { + // Fade out previous text + tl.to(text, { + opacity: 0, + scale: 0.8, + duration: 0.3, + ease: 'power2.in', + }) + // Animate cursor between messages (with varied size) + .to(cursor, { + scale: 1.8 + Math.random() * 0.4, // More constrained transition size + duration: 0.3, + ease: 'power2.in', + }) + // Set new message text while it's invisible + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + // Scale up cursor for final message (to random size) + .to(cursor, { + scale: 2.0 + Math.random() * 0.6, // More constrained between 2.0 and 2.6 + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + // Show the final message with styling + .to(text, { + opacity: 1, + color: textColor, + padding: '6px 10px', + borderRadius: '9999px', + boxShadow: boxShadowValue, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + scale: 1.2, + duration: 0.5, + ease: 'power2.out', + }); + + // Create a nested timeline for talking animation for the final message + const talkingTl = gsap.timeline(); + // Use the larger final size for the last message + // const finalSize = 2.0 + Math.random() * 0.6; + // createTalkingAnimation(talkingTl, cursor, message, finalSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 2-3 subtle size changes during final message (reduced from 3-5) + const numChanges = 2 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining time, then fade out + tl.to({}, { duration: changeInterval }) + // Add fade-out animation + .to(text, { + opacity: 0, + y: -20, + duration: 0.8, + ease: 'power2.in', + }) + .to(cursor, { + scale: 1.2, // Slightly expand before popping + duration: 0.15, + ease: 'power1.out', + }) + .to(cursor, { + scale: 0, + opacity: 0, + duration: 0.15, + ease: 'power4.in', // Sharper easing for more "pop" feeling + }) + .to({}, { duration: 0.5 }) // Add half-second delay + .call(() => onAnimationComplete?.()); + } + }); + }); + + return () => cursorCtx.current?.revert(); + }, [ + messages, + onAnimationComplete, + cursorColor, + tooltipBg, + tooltipBorderColor, + textColor, + boxShadowValue, + ]); + + // Handle global click to redirect - only when isAnimationComplete and isRedirected are true + useEffect(() => { + if (!isAnimationComplete || !isRedirected) return; + + const handleClick = () => { + // Open in a new tab instead of using router + window.open('/forecast', '_blank', 'noopener,noreferrer'); + }; + + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('click', handleClick); + }; + }, [isAnimationComplete, isRedirected]); + + return ( + <> + + + {/* Blocking overlay */} + {blocking && + typeof window !== 'undefined' && + createPortal( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + />, + document.body + )} + + {/* Added wrapper div with max z-index to create new stacking context */} +
+
+
+ {/* Initial message content - GSAP will update this during animation sequence */} + {messages[0]} +
+ {/* Container for particle effects */} +
+
+ + ); +} diff --git a/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx b/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx new file mode 100644 index 00000000..cf224f08 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx @@ -0,0 +1,242 @@ +import { motion } from 'framer-motion'; +import React, { useEffect, useRef } from 'react'; +import { cn } from '@/styles/stylingUtils'; +import { useStyling } from '@/store/CedarStore'; +import { createPortal } from 'react-dom'; + +interface ClickableAreaProps { + rect: DOMRect; + onClick?: () => void; + className?: string; + blocking?: boolean; // When true, creates an overlay to block clicks outside the target area + fadeOut?: boolean; // Controls whether the area and overlay should fade out + buffer?: number; // Optional buffer around the clickable area in pixels + disabled?: boolean; // When true, disables click interaction and blocks click propagation +} + +const ClickableArea: React.FC = ({ + rect, + onClick, + className, + blocking = false, + fadeOut = false, + buffer = 0, + disabled = false, +}: ClickableAreaProps) => { + const areaRef = useRef(null); + const { styling } = useStyling(); + + // Extract if this area has a ring style (which indicates it's used with blocking overlay) + const hasRingClass = className?.includes('ring-') || blocking; + + // Add event listeners for click and dragend events + useEffect(() => { + if (!rect || !onClick || disabled) return; + + // Add a flag to prevent duplicate calls within a short timeframe + let isHandlingClick = false; + + const isWithinBounds = (x: number, y: number) => { + return ( + x >= rect.left - buffer && + x <= rect.left + rect.width + buffer && + y >= rect.top - buffer && + y <= rect.top + rect.height + buffer + ); + }; + + // Shared handler for all events to prevent duplicates + const handleEvent = (e: MouseEvent | DragEvent) => { + if (isWithinBounds(e.clientX, e.clientY) && !isHandlingClick) { + isHandlingClick = true; + onClick(); + + // Reset the flag after a short delay + const timeout = setTimeout(() => { + isHandlingClick = false; + }, 100); // 100ms debounce + return () => clearTimeout(timeout); + } + }; + + // Add event listeners - using capture (true) for drag/drop events + window.addEventListener('click', handleEvent, true); + window.addEventListener('drop', handleEvent, true); // Using capture phase + + // Clean up on unmount + return () => { + window.removeEventListener('click', handleEvent, true); + window.removeEventListener('drop', handleEvent, true); // Match capture phase in cleanup + }; + }, [onClick, rect, buffer, disabled]); + + if (!rect) return null; + + // Use a stronger box shadow when this is being used with blocking overlay + const boxShadowStyle = + blocking || hasRingClass + ? `0 0 0 3px white, 0 0 0 6px ${ + styling.color || '#FFBFE9' + }, 0 0 30px 10px rgba(255, 255, 255, 0.9)` + : `0 0 0 2px white, 0 0 0 4px ${styling.color || '#FFBFE9'}`; + + return ( + <> + {/* Blocking overlay with a cut-out for the clickable area */} + {blocking && + rect && + typeof window !== 'undefined' && + createPortal( + <> + {/* Top overlay */} + {rect.top > 0 && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Left overlay */} + {rect.left > 0 && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Right overlay */} + {rect.left + rect.width < window.innerWidth && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Bottom overlay */} + {rect.top + rect.height < window.innerHeight && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + , + document.body + )} + + {/* The actual clickable area */} + { + if (disabled) { + e.preventDefault(); + e.stopPropagation(); + } + }} + aria-hidden='true' + /> + + ); +}; + +export default ClickableArea; diff --git a/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx b/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx new file mode 100644 index 00000000..f35b6428 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx @@ -0,0 +1,122 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import Container3D from '@/components/ui/Container3D'; +import GlowingMesh from '@/components/ui/GlowingMesh'; + +export interface DialogueBannerProps { + /** Optional children content to display instead of typewriter text */ + children?: React.ReactNode; + /** Optional text for typewriter or fallback if no children */ + text?: string; + style?: React.CSSProperties; + advanceMode?: + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean) + | ((nextAction: () => void) => void); + onComplete: () => void; +} + +const DialogueBanner: React.FC = ({ + text, + children, + style, + advanceMode = 'default', + onComplete, +}) => { + const [displayedText, setDisplayedText] = useState(''); + const timeoutRef = useRef(null); + const typingSpeed = 30; + + const isAuto = advanceMode === 'auto'; + const isNumericDelay = typeof advanceMode === 'number'; + const isFunctionPredicate = + typeof advanceMode === 'function' && + (advanceMode as () => boolean).length === 0; + const delayDuration = isNumericDelay ? (advanceMode as number) : 3000; + + useEffect(() => { + if (!isFunctionPredicate) return; + const predicate = advanceMode as () => boolean; + if (predicate()) { + onComplete(); + return; + } + const interval = setInterval(() => { + if (predicate()) { + onComplete(); + clearInterval(interval); + } + }, 500); + return () => clearInterval(interval); + }, [advanceMode, isFunctionPredicate, onComplete]); + + useEffect(() => { + // Skip typing effect if children provided + if (children) { + return; + } + const sourceText = text ?? ''; + let index = 0; + const interval = setInterval(() => { + if (index < sourceText.length) { + setDisplayedText(sourceText.substring(0, index + 1)); + index++; + } else { + clearInterval(interval); + if (isAuto) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => onComplete(), 5000); + } else if (isNumericDelay) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => onComplete(), delayDuration); + } + } + }, typingSpeed); + return () => { + clearInterval(interval); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [ + text, + advanceMode, + isAuto, + isNumericDelay, + delayDuration, + onComplete, + children, + ]); + + // Fully opaque center mask for stronger fade effect + const maskImage = + 'linear-gradient(to right, transparent 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, transparent 100%)'; + + const wrapperStyle: React.CSSProperties = { + position: 'fixed', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + width: '100%', + maxWidth: '42rem', + pointerEvents: 'none', + WebkitMaskImage: maskImage, + maskImage: maskImage, + }; + + return ( +
+ + {/* Render children if provided, else fallback to displayed text */} + {children ?? displayedText} + + +
+ ); +}; + +export default DialogueBanner; diff --git a/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx b/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx new file mode 100644 index 00000000..c7fd3bdd --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx @@ -0,0 +1,252 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { useStyling } from '@/store/CedarStore'; +import { createPortal } from 'react-dom'; + +export interface DialogueBoxProps { + text: string; + style?: React.CSSProperties; + advanceMode: 'auto' | 'external' | 'default' | number | (() => boolean); + onComplete: () => void; + blocking?: boolean; // When true, creates an overlay to block clicks outside the dialogue +} + +const DialogueBox: React.FC = ({ + text, + style, + advanceMode, + onComplete, + blocking = false, +}) => { + const { styling } = useStyling(); + const [displayedText, setDisplayedText] = useState(''); + const [isTypingComplete, setIsTypingComplete] = useState(false); + const typingSpeed = 30; // ms per character + const timeoutRef = useRef(null); + + // Determine behavior based on advanceMode + const isAuto = advanceMode === 'auto'; + const isNumericDelay = typeof advanceMode === 'number'; + const isFunctionMode = typeof advanceMode === 'function'; + const delayDuration = isNumericDelay ? advanceMode : 3000; // Default 3s delay for 'auto' + + // Call advanceMode function on initialization if it's a function + useEffect(() => { + // Only set up interval if we have a function mode + if (!isFunctionMode) return; + + // First, check immediately + const shouldAdvance = (advanceMode as () => boolean)(); + if (shouldAdvance) { + // If function returns true, complete the dialogue immediately + onComplete(); + return; // Exit early, no need to set up interval + } + + // Set up interval to periodically check the condition (every 500ms) + const checkInterval = setInterval(() => { + // Call the function and check if we should advance + const shouldAdvance = (advanceMode as () => boolean)(); + if (shouldAdvance) { + // If function returns true, complete the dialogue + onComplete(); + // Clear the interval once we've advanced + clearInterval(checkInterval); + } + }, 500); // Check every 500ms + + // Clean up when component unmounts + return () => { + clearInterval(checkInterval); + }; + }, [isFunctionMode, advanceMode, onComplete]); + + // Handle typing animation effect + useEffect(() => { + let currentIndex = 0; + const typingInterval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.substring(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(typingInterval); + setIsTypingComplete(true); + + // When typing completes, handle auto advance + if (isAuto || isNumericDelay) { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set timeout to advance after the specified delay + timeoutRef.current = setTimeout(() => { + onComplete(); + }, delayDuration); + } + } + }, typingSpeed); + + return () => { + clearInterval(typingInterval); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [text, advanceMode, onComplete, isAuto, isNumericDelay, delayDuration]); + + // Handler for advancing the dialogue + const handleAdvanceDialogue = () => { + if (isTypingComplete && advanceMode === 'default') { + onComplete(); + } + }; + + // Global click handler for screen + useEffect(() => { + // Only add the event listener when typing is complete and advanceMode is 'default' + if (isTypingComplete && advanceMode === 'default') { + window.addEventListener('click', handleAdvanceDialogue); + } + + return () => { + window.removeEventListener('click', handleAdvanceDialogue); + }; + }, [isTypingComplete, advanceMode, onComplete]); + + // Define the box shadow style similar to ClickableArea + const boxShadowStyle = `0 0 0 2px white, 0 0 0 4px ${styling.color || '#FFBFE9'}, 0 0 30px rgba(255, 255, 255, 0.8)`; + + // Function to safely render the icon component + const renderIconComponent = () => { + // If iconComponent doesn't exist, return null + if (!styling.iconComponent) { + return null; + } + + // Create a wrapper component for the icon + return ( + + {React.isValidElement(styling.iconComponent) + ? styling.iconComponent + : null} + + ); + }; + + // Create the combined content that will be placed in the portal + const dialogueContent = ( + <> + {/* Blocking overlay to prevent interactions outside the dialogue */} + {blocking && ( + { + // Allow clicks on the overlay to advance the dialogue + if (isTypingComplete && advanceMode === 'default') { + e.preventDefault(); + e.stopPropagation(); + handleAdvanceDialogue(); + } + }} + aria-hidden='true' + /> + )} + + +
{ + // Prevent click from propagating to window + e.stopPropagation(); + // If typing is complete and mode is default, complete dialogue + if (isTypingComplete && advanceMode === 'default') { + handleAdvanceDialogue(); + } + }}> + {/* Static size container that establishes dimensions based on full text */} +
+ {/* Hidden full text to establish exact dimensions */} + + + {/* Visible animated text positioned absolutely within the sized container */} +
+ {displayedText} +
+
+ + {/* Continue indicator */} + {isTypingComplete && advanceMode === 'default' && ( + + Click anywhere to continue... + + )} + + {/* Custom icon component or default decorative accent */} + {styling.iconComponent && + React.isValidElement(styling.iconComponent) ? ( + renderIconComponent() + ) : ( + + )} +
+
+ + ); + + // Use createPortal to insert both elements directly into the document body + return typeof window !== 'undefined' + ? createPortal(dialogueContent, document.body) + : null; +}; + +export default DialogueBox; diff --git a/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx b/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx new file mode 100644 index 00000000..543f42a5 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx @@ -0,0 +1,260 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Position } from '@/components/guidance/utils/positionUtils'; +import { useStyling } from '@/store/CedarStore'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import { SPRING_CONFIGS } from '@/components/guidance/utils/constants'; + +interface EdgePointerProps { + startPosition: Position; // Starting position, same as BaseCursor + endRect: DOMRect; // The endRect from VirtualCursor to determine edge position + fadeOut?: boolean; + tooltipText?: string; + onAnimationComplete?: () => void; + onTooltipComplete?: () => void; + shouldAnimateStartMotion: boolean; +} + +/** + * Calculate the position and rotation of the edge pointer + * @param targetRect The target DOM rect + * @returns Position, angle, and edge information + */ +const calculateEdgePointerPosition = ( + targetRect: DOMRect +): { + position: Position; + angle: number; + edge: 'top' | 'right' | 'bottom' | 'left'; +} => { + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Calculate center of viewport and target + const centerX = viewportWidth / 2; + const centerY = viewportHeight / 2; + const targetX = targetRect.left + targetRect.width / 2; + const targetY = targetRect.top + targetRect.height / 2; + + // Edge padding for better visibility + const edgePadding = 40; + const rightEdgePadding = 60; // Extra padding for right edge to account for scrollbars + + // Calculate angle from center to target + const angleRad = Math.atan2(targetY - centerY, targetX - centerX); + const angleDeg = (angleRad * 180) / Math.PI + 135; + + // Determine which edge the pointer should be placed on + let edge: 'top' | 'right' | 'bottom' | 'left'; + let edgeX: number; + let edgeY: number; + + // Check if target is off the right side + if (targetRect.left > viewportWidth) { + edge = 'right'; + edgeX = viewportWidth - rightEdgePadding; + edgeY = Math.max( + edgePadding, + Math.min(viewportHeight - edgePadding, targetY) + ); + } + // Check if target is off the left side + else if (targetRect.right < 0) { + edge = 'left'; + edgeX = edgePadding; + edgeY = Math.max( + edgePadding, + Math.min(viewportHeight - edgePadding, targetY) + ); + } + // Check if target is off the bottom + else if (targetRect.top > viewportHeight) { + edge = 'bottom'; + edgeY = viewportHeight - edgePadding; + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, targetX) + ); + } + // Check if target is off the top + else if (targetRect.bottom < 0) { + edge = 'top'; + edgeY = edgePadding; + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, targetX) + ); + } + // Fallback - choose the closest edge + else { + const distToRight = viewportWidth - targetX; + const distToLeft = targetX; + const distToBottom = viewportHeight - targetY; + const distToTop = targetY; + + const minDist = Math.min(distToRight, distToLeft, distToBottom, distToTop); + + if (minDist === distToRight) { + edge = 'right'; + edgeX = viewportWidth - rightEdgePadding; + edgeY = targetY; + } else if (minDist === distToLeft) { + edge = 'left'; + edgeX = edgePadding; + edgeY = targetY; + } else if (minDist === distToBottom) { + edge = 'bottom'; + edgeY = viewportHeight - edgePadding; + edgeX = targetX; + } else { + edge = 'top'; + edgeY = edgePadding; + edgeX = targetX; + } + } + + // Ensure we stay within viewport bounds + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, edgeX) + ); + edgeY = Math.max(edgePadding, Math.min(viewportHeight - edgePadding, edgeY)); + + return { + position: { x: edgeX, y: edgeY }, + angle: angleDeg, + edge, + }; +}; + +const EdgePointer: React.FC = ({ + startPosition, + endRect, + fadeOut = false, + tooltipText = 'The element is off screen here!', + onAnimationComplete, + onTooltipComplete, + shouldAnimateStartMotion, +}) => { + const { styling } = useStyling(); + const [showTooltip, setShowTooltip] = useState(false); + // Calculate edge pointer position from the endRect + const pointerData = calculateEdgePointerPosition(endRect); + + // Get the appropriate spring configuration + const actualSpringConfig = fadeOut + ? SPRING_CONFIGS.FADEOUT + : SPRING_CONFIGS.EDGE_POINTER; + + // Handler for cursor animation completion + const handleAnimationComplete = () => { + if (onAnimationComplete) { + onAnimationComplete(); + } + + // Show tooltip after animation completes + if (tooltipText) { + setShowTooltip(true); + } + }; + + // Handler for tooltip completion + const handleTooltipComplete = () => { + if (onTooltipComplete) { + onTooltipComplete(); + } + }; + + // Convert edge to tooltip position + const getTooltipPosition = (): 'left' | 'right' | 'top' | 'bottom' => { + // Return the opposite direction of the edge + switch (pointerData.edge) { + case 'top': + return 'bottom'; // If pointer is at top edge, tooltip below it + case 'right': + return 'left'; // If pointer is at right edge, tooltip left of it + case 'bottom': + return 'top'; // If pointer is at bottom edge, tooltip above it + case 'left': + return 'right'; // If pointer is at left edge, tooltip right of it + default: + return 'top'; + } + }; + + // Create a fake DOMRect to position the tooltip correctly + const createPointerRect = (): DOMRect => { + return { + x: pointerData.position.x, + y: pointerData.position.y, + width: 28, + height: 28, + top: pointerData.position.y - 16, + right: pointerData.position.x + 20, + bottom: pointerData.position.y + 20, + left: pointerData.position.x - 16, + toJSON: () => {}, + }; + }; + + return ( + + {/* Cursor SVG */} + + + + + {/* Tooltip */} + {tooltipText && showTooltip && ( + + )} + + ); +}; + +export default EdgePointer; diff --git a/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx b/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx new file mode 100644 index 00000000..3e9db973 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef } from 'react'; +import { + PositionOrElement, + LazyPositionOrElement, +} from '@/components/guidance/utils/positionUtils'; +import { + isInputElement, + startTypingAnimation, + Timeout, +} from '@/components/guidance/utils/typingUtils'; + +interface ExecuteTypingProps { + endPosition: PositionOrElement; + expectedValue: string; + onComplete?: () => void; +} + +const ExecuteTyping: React.FC = ({ + endPosition, + expectedValue, + onComplete, +}) => { + const typingTimeoutRef = useRef(null); + + useEffect(() => { + // Properly resolve the endPosition if it's a lazy reference + let resolvedEndPosition = endPosition; + if ( + endPosition && + typeof endPosition === 'object' && + '_lazy' in endPosition + ) { + resolvedEndPosition = (endPosition as LazyPositionOrElement).resolve(); + } + + // Validate that we have a proper element + const isValidElement = + resolvedEndPosition && + typeof resolvedEndPosition === 'object' && + (resolvedEndPosition as HTMLElement).getBoundingClientRect; + + // Get the actual element + const endElement = isValidElement + ? (resolvedEndPosition as HTMLElement) + : null; + + if (!endElement || !isInputElement(endElement)) { + console.error('ExecuteTyping: Invalid or non-input element provided'); + onComplete?.(); + return; + } + + if (typeof expectedValue !== 'string') { + console.error('ExecuteTyping: Only string expectedValue is supported'); + onComplete?.(); + return; + } + + // Start typing immediately + startTypingAnimation( + endElement, + expectedValue, + 150, // default typing delay + true, // check existing value + () => { + onComplete?.(); + }, + typingTimeoutRef + ); + + // Cleanup on unmount + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + }, [endPosition, expectedValue, onComplete]); + + // This component doesn't render anything visible + return null; +}; + +export default ExecuteTyping; diff --git a/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx b/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx new file mode 100644 index 00000000..a05ed9e3 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx @@ -0,0 +1,184 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + getRectFromPositionOrElement, + PositionOrElement, +} from '../utils/positionUtils'; +import ClickableArea from './ClickableArea'; + +export interface HighlightOverlayProps { + elements: + | PositionOrElement[] + | { _lazy: true; resolve: () => PositionOrElement[] }; + shouldScroll?: boolean; +} + +export const HighlightOverlay: React.FC = ({ + elements, + shouldScroll = true, +}) => { + // Store element rects in state + const [elementRects, setElementRects] = useState<(DOMRect | null)[]>([]); + // Store resolved elements + const [resolvedElements, setResolvedElements] = useState( + [] + ); + // Store element refs for tracking + const elementsRef = useRef(resolvedElements); + // Store original elements prop for tracking + const originalElementsRef = useRef(elements); + // Animation frame ID for requestAnimationFrame + const rafRef = useRef(null); + // Last timestamp for throttling updates + const lastUpdateTimeRef = useRef(0); + // Last timestamp for resolving lazy elements + const lastResolveTimeRef = useRef(0); + // Update interval in ms + const updateInterval = 100; + // Resolver update interval in ms (refresh lazy elements every 500ms) + const resolverUpdateInterval = 500; + // Flag to track if we have a lazy resolver + const isLazyRef = useRef(false); + + // Update original elements ref when prop changes + useEffect(() => { + originalElementsRef.current = elements; + // Determine if we have a lazy resolver + isLazyRef.current = !!( + elements && + typeof elements === 'object' && + '_lazy' in elements && + elements._lazy + ); + }, [elements]); + + // Function to resolve elements from a lazy resolver + const resolveElements = useCallback(() => { + const currentElements = originalElementsRef.current; + + if ( + currentElements && + typeof currentElements === 'object' && + '_lazy' in currentElements && + currentElements._lazy + ) { + try { + // Resolve the elements and update state + const resolved = currentElements.resolve(); + setResolvedElements(resolved || []); + } catch (error) { + console.error('Error resolving lazy elements:', error); + setResolvedElements([]); + } + } else if (Array.isArray(currentElements)) { + // If not lazy, just use the elements directly + setResolvedElements(currentElements); + } + }, []); + + // Function to update the element rects based on current positions + const updateElementRects = useCallback( + (timestamp?: number) => { + // If we have a lazy resolver and enough time has passed, resolve elements again + if ( + isLazyRef.current && + timestamp && + timestamp - lastResolveTimeRef.current >= resolverUpdateInterval + ) { + resolveElements(); + lastResolveTimeRef.current = timestamp; + } + + if (!elementsRef.current || elementsRef.current.length === 0) return; + + const newRects = elementsRef.current.map((element) => + getRectFromPositionOrElement(element, 10, shouldScroll) + ); + + // Only update state if there's a change to avoid unnecessary rerenders + const hasChanged = newRects.some((rect, index) => { + const currentRect = elementRects[index]; + return ( + !currentRect || + !rect || + rect.x !== currentRect.x || + rect.y !== currentRect.y || + rect.width !== currentRect.width || + rect.height !== currentRect.height + ); + }); + + if (hasChanged || elementRects.length !== newRects.length) { + setElementRects(newRects); + } + }, + [elementRects, resolveElements, shouldScroll] + ); + + // The animation frame loop function with throttling + const animationFrameLoop = useCallback( + (timestamp: number) => { + // Only update if enough time has passed since last update + if (timestamp - lastUpdateTimeRef.current >= updateInterval) { + updateElementRects(timestamp); + lastUpdateTimeRef.current = timestamp; + } + + // Continue the loop by requesting the next frame + rafRef.current = requestAnimationFrame(animationFrameLoop); + }, + [updateElementRects] + ); + + // Initial resolution of elements when component mounts or elements prop changes + useEffect(() => { + resolveElements(); + }, [resolveElements]); + + // Update elementsRef when resolved elements change + useEffect(() => { + elementsRef.current = resolvedElements; + // Initial update right away + updateElementRects(); + }, [resolvedElements, updateElementRects]); + + // Set up position tracking using requestAnimationFrame + useEffect(() => { + // Initial update right away + updateElementRects(); + + // Start the animation frame loop + rafRef.current = requestAnimationFrame(animationFrameLoop); + + // Clean up animation frame on unmount + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [animationFrameLoop, updateElementRects]); + + // Don't render anything if no elements to highlight + if (!elementRects.length) return null; + + return ( + <> + {elementRects.map((rect, index) => + rect ? ( + + + + ) : null + )} + + ); +}; + +export default HighlightOverlay; diff --git a/packages/cedar-os/src/components/guidance/components/IFActionRenderer.tsx b/packages/cedar-os/src/components/guidance/components/IFActionRenderer.tsx new file mode 100644 index 00000000..5ecf853b --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/IFActionRenderer.tsx @@ -0,0 +1,601 @@ +import React, { useEffect, useCallback } from 'react'; +import { useActions } from '@/store/CedarStore'; +import { IFAction, Action, VirtualClickAction } from '@/store/actionsSlice'; +import VirtualCursor from './VirtualCursor'; +import VirtualTypingCursor from './VirtualTypingCursor'; +import ExecuteTyping from './ExecuteTyping'; +import { CedarCursor } from './CedarCursor'; +import DialogueBox from './DialogueBox'; +import SurveyDialog from './SurveyDialog'; +import TooltipText from './TooltipText'; +import ToastCard from '@/components/ToastCard'; +import { motion } from 'framer-motion'; +import { PositionOrElement } from '../utils/positionUtils'; + +interface IFActionRendererProps { + action: IFAction; + actionKey: string; + prevCursorPosition: { x: number; y: number } | null; + isAnimatingOut: boolean; + handleActionEnd: () => void; + handleMultiClickComplete: () => void; + currentClickIndex: number; + executeClick: () => void; + dragIterationCount: number; + isDragAnimatingOut: boolean; + setDragIterationCount: React.Dispatch>; + setIsDragAnimatingOut: React.Dispatch>; +} + +const IFActionRenderer: React.FC = ({ + action, + actionKey, + prevCursorPosition, + isAnimatingOut, + handleActionEnd, + handleMultiClickComplete, + currentClickIndex, + executeClick, + dragIterationCount, + isDragAnimatingOut, + setDragIterationCount, + setIsDragAnimatingOut, +}) => { + const { nextAction } = useActions(); + const [currentConditionResult, setCurrentConditionResult] = + React.useState(false); + const [currentRenderedAction, setCurrentRenderedAction] = + React.useState(null); + + // Function to evaluate condition + const evaluateCondition = useCallback(async () => { + try { + // Get initial result + const result = + typeof action.condition === 'function' + ? action.condition() + : action.condition; + + // Check if result is a Promise + if ( + typeof result === 'object' && + result !== null && + 'then' in result && + typeof result.then === 'function' + ) { + // Await the Promise to get the resolved value + return await result; + } else { + // Use the synchronous result + return !!result; + } + } catch (error) { + console.error('Error evaluating IF condition:', error); + return false; + } + }, [action.condition]); + + // Function to check advance condition + const checkAdvanceCondition = useCallback(() => { + if (!action.advanceCondition) return false; + + if (typeof action.advanceCondition === 'function') { + if (action.advanceCondition.length >= 1) { + return false; + } + return (action.advanceCondition as () => boolean)(); + } else if (action.advanceCondition === 'auto') { + return true; + } else if (action.advanceCondition === 'default') { + return false; + } else if (typeof action.advanceCondition === 'number') { + return false; + } + + return false; + }, [action.advanceCondition]); + + // Set up initial rendered action and interval for checking conditions + useEffect(() => { + // Set initial condition and rendered action + const setupInitialState = async () => { + try { + const initialCondition = await evaluateCondition(); + setCurrentConditionResult(initialCondition); + + // Set the initial action to render based on condition + const childAction = initialCondition + ? (action.trueAction as Action) + : (action.falseAction as Action); + + setCurrentRenderedAction(childAction); + } catch (error) { + console.error('Error setting up initial state:', error); + // Default to false action on error + setCurrentConditionResult(false); + setCurrentRenderedAction(action.falseAction as Action); + } + }; + + setupInitialState(); + + // Invoke callback-style advanceCondition once if provided (length >= 1) + if ( + typeof action.advanceCondition === 'function' && + action.advanceCondition.length >= 1 + ) { + (action.advanceCondition as (next: () => void) => void)(() => { + nextAction(action.id); + }); + } + + // Set up interval to check conditions (200ms as specified) + const intervalId = setInterval(() => { + // First check if we should advance to next action + if (checkAdvanceCondition()) { + nextAction(action.id); + return; + } + + // Check if we need to change the rendered action + const checkCondition = async () => { + try { + const newCondition = await evaluateCondition(); + + if (newCondition !== currentConditionResult) { + setCurrentConditionResult(newCondition); + + // Update the rendered action based on new condition + const newChildAction = newCondition + ? (action.trueAction as Action) + : (action.falseAction as Action); + + setCurrentRenderedAction(newChildAction); + } + } catch (error) { + console.error('Error checking condition:', error); + } + }; + + checkCondition(); + }, 200); + + // Handle numeric advanceCondition + let advanceTimeoutId: NodeJS.Timeout | null = null; + if (typeof action.advanceCondition === 'number') { + advanceTimeoutId = setTimeout(() => { + nextAction(action.id); + }, action.advanceCondition); + } + + // Cleanup interval on unmount + return () => { + clearInterval(intervalId); + if (advanceTimeoutId) clearTimeout(advanceTimeoutId); + }; + }, [ + action, + evaluateCondition, + nextAction, + checkAdvanceCondition, + currentConditionResult, + ]); + + const handleCursorAnimationComplete = useCallback( + (clicked: boolean) => { + // Use type guards for different action types + if (currentRenderedAction?.type === 'VIRTUAL_CLICK') { + const clickAction = currentRenderedAction as VirtualClickAction; + if ( + clickAction.advanceMode !== 'external' && + typeof clickAction.advanceMode !== 'function' + ) { + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setIsDragAnimatingOut(false); + return handleActionEnd(); + }, 300); // Duration of fadeout animation + } + } + + // For VIRTUAL_DRAG with external advance mode, loop the animation + if (currentRenderedAction?.type === 'VIRTUAL_DRAG') { + // CARE -> it should default to clickable + if (clicked && currentRenderedAction.advanceMode !== 'external') { + return handleActionEnd(); + } + + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }, + [handleActionEnd, currentRenderedAction] + ); + + // If we don't have a selected action yet, don't render anything + if (!currentRenderedAction) { + return null; + } + + // Render the appropriate component based on the selected action type + const renderActionContent = () => { + switch (currentRenderedAction.type) { + case 'CURSOR_TAKEOVER': + return ( + + ); + + case 'VIRTUAL_CLICK': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentRenderedAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + // Determine the advanceMode from the action + const rawAdvanceMode = currentRenderedAction.advanceMode; + type CursorAdvanceMode = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + const advanceMode: CursorAdvanceMode = + typeof rawAdvanceMode === 'function' && rawAdvanceMode.length >= 1 + ? 'default' + : (rawAdvanceMode as CursorAdvanceMode) || 'default'; + + return ( + + + + ); + } + + case 'VIRTUAL_DRAG': { + // Determine the start position - use action.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentRenderedAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + { + // CARE -> it should default to clickable + if ( + clicked && + currentRenderedAction.advanceMode !== 'external' + ) { + handleActionEnd(); + } else { + // For VIRTUAL_DRAG with external advance mode, loop the animation + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }} + tooltipPosition={currentRenderedAction.tooltipPosition} + tooltipAnchor={currentRenderedAction.tooltipAnchor} + startTooltip={currentRenderedAction.startTooltip} + advanceMode={'auto'} + shouldScroll={currentRenderedAction.shouldScroll} + dragCursor={currentRenderedAction.dragCursor !== false} + /> + + ); + } + + case 'MULTI_VIRTUAL_CLICK': { + // Determine the current click action + const currentClickAction = + currentRenderedAction.actions[currentClickIndex]; + + // Determine the start position based on the click index + let startPosition: PositionOrElement | undefined; + if (currentClickIndex === 0) { + // For the first click, use the previous cursor position or the specified start position + startPosition = + currentClickAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + } else { + // For subsequent clicks, always use their specified start position if available, + // otherwise fallback to the end position of the previous click + startPosition = + currentClickAction.startPosition || + currentRenderedAction.actions[currentClickIndex - 1].endPosition; + } + + // Use the same advanceMode calculation as for VIRTUAL_CLICK + const rawAdvanceModeMulti = currentClickAction.advanceMode; + type CursorAdvanceModeMulti = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + const advanceMode: CursorAdvanceModeMulti = + typeof rawAdvanceModeMulti === 'function' && + rawAdvanceModeMulti.length >= 1 + ? 'default' + : (rawAdvanceModeMulti as CursorAdvanceModeMulti) || 'default'; + + return ( + + ); + } + + case 'VIRTUAL_TYPING': { + // Determine the start position - use action.startPosition if provided, + const typingStartPosition: PositionOrElement | undefined = + currentRenderedAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + ); + } + + case 'CHAT_TOOLTIP': { + // Find the chat button to position the tooltip + const chatButton = + document.querySelector('.CedarChatButton') || + document.querySelector('[data-cedar-chat-button]'); + const chatButtonRect = chatButton?.getBoundingClientRect(); + + if (!chatButtonRect) { + // If chat button not found, complete this action and go to next + setTimeout(handleActionEnd, 100); + return null; + } + + // Calculate centered position above chat button + // We know TooltipText with position="top" will apply translateY(-100%) + // So we place this at the center top of the button + const tooltipPosition = { + left: chatButtonRect.left + chatButtonRect.width / 2, + top: chatButtonRect.top - 15, // Add a small vertical offset + }; + + return ( + + + + ); + } + + case 'IDLE': + return null; + + case 'DIALOGUE': + return ( + boolean) => { + const am = currentRenderedAction.advanceMode; + if (typeof am === 'function' && am.length >= 1) { + return 'default'; + } + return ( + (am as + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean)) || 'default' + ); + })()} + blocking={currentRenderedAction.blocking} + onComplete={handleActionEnd} + /> + ); + + case 'SURVEY': + return ( + { + if (!open && !currentRenderedAction.blocking) { + handleActionEnd(); + } + }} + submitButtonText={currentRenderedAction.submitButtonText} + cancelButtonText={currentRenderedAction.cancelButtonText} + onSubmit={(responses) => { + currentRenderedAction.onSubmit?.(responses); + handleActionEnd(); + }} + blocking={currentRenderedAction.blocking} + trigger_id={currentRenderedAction.trigger_id} + /> + ); + + case 'EXECUTE_CLICK': { + const showCursor = currentRenderedAction.showCursor !== false; + + if (showCursor) { + const resolvedStartPosition: PositionOrElement | undefined = + currentRenderedAction.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + return null; + } + + case 'TOAST': { + return ( + nextAction(currentRenderedAction.id)} + /> + ); + } + + case 'EXECUTE_TYPING': { + return ( + + ); + } + + default: + console.error( + 'Unknown action type in IF condition:', + currentRenderedAction + ); + return null; + } + }; + + return renderActionContent(); +}; + +export default IFActionRenderer; diff --git a/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx b/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx new file mode 100644 index 00000000..507935f0 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useStyling } from '@/store/CedarStore'; + +export interface ProgressPointsProps { + /** + * Total number of steps in the sequence + */ + totalSteps: number; + + /** + * Current active step (1-based) + */ + currentStep: number; + + /** + * Whether to show the component + */ + visible?: boolean; + + /** + * Optional custom label for each step + */ + labels?: string[]; + + /** + * Optional callback when a step is clicked + */ + onStepClick?: (step: number) => void; +} + +const ProgressPoints: React.FC = ({ + totalSteps, + currentStep, + visible = true, + labels = [], + onStepClick, +}) => { + const { styling } = useStyling(); + const primaryColor = styling?.color || '#319B72'; + + // Create array of steps + const steps = Array.from({ length: totalSteps }, (_, i) => ({ + number: i + 1, + label: labels[i] || `Step ${i + 1}`, + status: + i + 1 < currentStep + ? 'completed' + : i + 1 === currentStep + ? 'active' + : 'pending', + })); + + // Handle navigation + const handlePrevious = () => { + if (currentStep > 1 && onStepClick) { + onStepClick(currentStep - 1); + } + }; + + const handleNext = () => { + if (currentStep < totalSteps && onStepClick) { + onStepClick(currentStep + 1); + } + }; + + // Determine if navigation arrows should be enabled + const canGoPrevious = currentStep > 1; + const canGoNext = currentStep < totalSteps; + + // Simplified animation variants + const containerVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.3, + ease: 'easeOut', + }, + }, + exit: { + opacity: 0, + y: 20, + transition: { + duration: 0.2, + ease: 'easeIn', + }, + }, + }; + + return ( + + {visible && ( +
+ +
+ {/* Previous arrow */} + + + + + + + {/* Steps */} +
+ {steps.map((step) => ( + onStepClick?.(step.number)}> + {/* Glow effect for active step */} + {step.status === 'active' && ( + + )} + + {/* Number indicator for completed steps */} + {step.status === 'completed' && ( + + ✓ + + )} + + + {step.label} + + + ))} +
+ + {/* Next arrow */} + + + + + +
+
+
+ )} +
+ ); +}; + +export default ProgressPoints; diff --git a/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx b/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx new file mode 100644 index 00000000..af0314a8 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +interface RightClickIndicatorProps { + /** automatically hide after milliseconds (optional) */ + duration?: number; + onComplete?: () => void; +} + +const OFFSET = { x: 12, y: -12 }; + +const RightClickIndicator: React.FC = ({ + duration, + onComplete, +}) => { + const [pos, setPos] = useState<{ x: number; y: number }>({ + x: -9999, + y: -9999, + }); + + // Track mouse position + useEffect(() => { + const handleMove = (e: MouseEvent) => { + setPos({ x: e.clientX + OFFSET.x, y: e.clientY + OFFSET.y }); + }; + window.addEventListener('mousemove', handleMove); + return () => window.removeEventListener('mousemove', handleMove); + }, []); + + // Auto-complete after duration + useEffect(() => { + if (!duration) return; + const t = setTimeout(() => onComplete?.(), duration); + return () => clearTimeout(t); + }, [duration, onComplete]); + + return ( +
+
+ {/* Mouse icon */} + + {/* outer shape */} + + {/* middle divider */} + + {/* right button highlight */} + + + Back +
+
+ ); +}; + +export default RightClickIndicator; diff --git a/packages/cedar-os/src/components/guidance/components/SurveyDialog.tsx b/packages/cedar-os/src/components/guidance/components/SurveyDialog.tsx new file mode 100644 index 00000000..0c0f694b --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/SurveyDialog.tsx @@ -0,0 +1,553 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { ThumbsDown, ThumbsUp } from 'lucide-react'; +import { SurveyQuestion } from '@/store/actionsSlice'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/ui/dialog'; +import { getSupabaseClient } from '@/utils/supabase'; +import { useConfig, useStyling, useActions } from '@/store/CedarStore'; +import { v4 as uuidv4 } from 'uuid'; + +interface SurveyDialogProps { + title?: string; + description?: string; + questions: SurveyQuestion[]; + open: boolean; + onOpenChange: (open: boolean) => void; + submitButtonText?: string; + cancelButtonText?: string; + onSubmit?: (responses: Record) => void; + blocking?: boolean; + trigger_id?: string; + viewOnly?: boolean; + initialResponses?: Record; +} + +const SurveyDialog: React.FC = ({ + title = 'Share Your Feedback', + description = 'We would love to hear your thoughts to improve our service.', + questions, + open, + onOpenChange, + submitButtonText = 'Submit', + cancelButtonText = 'Cancel', + onSubmit, + blocking = false, + trigger_id, + viewOnly = false, + initialResponses = {}, +}) => { + const { currentAction, setCurrentAction } = useActions(); + const [responses, setResponses] = useState< + Record + >(() => { + // Initialize with initialResponses + const initial = { ...initialResponses }; + + // Add any slider default values that aren't already set + questions.forEach((question) => { + if ( + question.type === 'slider' && + 'defaultValue' in question && + question.defaultValue !== undefined && + initial[question.id] === undefined + ) { + initial[question.id] = question.defaultValue; + } + }); + + return initial; + }); + const [errors, setErrors] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + const [hoverValue, setHoverValue] = useState>( + {} + ); + const { productId, validateOrCreateProductUser, userId } = useConfig(); + const { styling } = useStyling(); + + const updateQuestionValue = useCallback( + (id: string, value: string | number | boolean) => { + if (currentAction?.type === 'SURVEY') { + const updatedQuestions = currentAction.questions.map((q) => + q.id === id ? { ...q, value } : q + ); + setCurrentAction({ + ...currentAction, + questions: updatedQuestions, + }); + } + }, + [currentAction, setCurrentAction] + ); + + const handleTextChange = (id: string, value: string) => { + setResponses((prev) => ({ ...prev, [id]: value })); + updateQuestionValue(id, value); + if (errors[id]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[id]; + return newErrors; + }); + } + }; + + const handleNumberChange = (id: string, value: string) => { + const numValue = value === '' ? '' : Number(value); + setResponses((prev) => ({ ...prev, [id]: numValue })); + updateQuestionValue(id, numValue); + if (errors[id]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[id]; + return newErrors; + }); + } + }; + + const handleSliderChange = (id: string, value: number) => { + setResponses((prev) => ({ ...prev, [id]: value })); + updateQuestionValue(id, value); + if (errors[id]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[id]; + return newErrors; + }); + } + }; + + const handleNpsChange = (id: string, value: number) => { + setResponses((prev) => ({ ...prev, [id]: value })); + updateQuestionValue(id, value); + if (errors[id]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[id]; + return newErrors; + }); + } + }; + + const handleThumbsChange = (id: string, value: boolean) => { + setResponses((prev) => ({ ...prev, [id]: value })); + updateQuestionValue(id, value); + }; + + const handleNpsHover = (id: string, value: number | null) => { + setHoverValue((prev) => ({ ...prev, [id]: value })); + }; + + const validateResponses = () => { + const newErrors: Record = {}; + let isValid = true; + + questions.forEach((question) => { + // Handle required fields + if ( + question.required && + (responses[question.id] === undefined || responses[question.id] === '') + ) { + newErrors[question.id] = 'This field is required'; + isValid = false; + } + + // Special case for NPS - must explicitly select a value if required + if ( + question.type === 'nps' && + question.required && + responses[question.id] === undefined + ) { + newErrors[question.id] = 'Please select a rating'; + isValid = false; + } + + if ( + (question.type === 'number' || + question.type === 'slider' || + question.type === 'nps') && + responses[question.id] !== undefined && + responses[question.id] !== '' && + typeof responses[question.id] === 'number' && + 'min' in question && + 'max' in question + ) { + const value = responses[question.id] as number; + if (question.min !== undefined && value < question.min) { + newErrors[question.id] = `Value must be at least ${question.min}`; + isValid = false; + } + if (question.max !== undefined && value > question.max) { + newErrors[question.id] = `Value must be at most ${question.max}`; + isValid = false; + } + } + }); + + setErrors(newErrors); + return isValid; + }; + + const saveFeedbackToSupabase = async () => { + try { + setIsSaving(true); + const supabase = getSupabaseClient(); + + const { success, error } = await validateOrCreateProductUser(); + + if (!success) { + console.error('Error validating product user:', error); + return false; + } + + // Create a new feedback entry + const feedbackId = uuidv4(); + + const { error: feedbackError } = await supabase.from('feedback').insert({ + id: feedbackId, + product_id: productId || null, + product_user_id: userId || null, + trigger_id: trigger_id || null, + link: null, + }); + + if (feedbackError) { + console.error('Error saving feedback:', feedbackError); + return false; + } + + // Create response entries for each question + const responseEntries = questions.map((question) => { + const answer = responses[question.id]; + return { + id: uuidv4(), + feedback_id: feedbackId, + question: question.question, + answer: answer !== undefined ? String(answer) : null, + type: question.type, + product_id: productId || undefined, + }; + }); + + const { error: responsesError } = await supabase + .from('feedback_responses') + .insert(responseEntries); + + if (responsesError) { + console.error('Error saving responses:', responsesError); + return false; + } + + return true; + } catch (error) { + console.error('Unexpected error saving feedback:', error); + return false; + } finally { + setIsSaving(false); + } + }; + + const handleSubmit = async () => { + if (validateResponses()) { + // Save to Supabase + const saveSuccessful = await saveFeedbackToSupabase(); + // Only close if not blocking and save was successful + if (saveSuccessful) { + onSubmit?.(responses); + onOpenChange(false); + } + } + }; + + const handleCancel = () => { + if (!blocking) { + onOpenChange(false); + } + }; + + const renderQuestion = (question: SurveyQuestion) => { + switch (question.type) { + case 'shortText': + return ( +
+ + handleTextChange(question.id, e.target.value)} + disabled={viewOnly} + /> + {errors[question.id] && ( +

{errors[question.id]}

+ )} +
+ ); + + case 'longText': + return ( +
+ +