diff --git a/apps/web/client/src/app/project/[id]/_components/bottom-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/bottom-bar/index.tsx index accafb390c..3d92b6ec61 100644 --- a/apps/web/client/src/app/project/[id]/_components/bottom-bar/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/bottom-bar/index.tsx @@ -1,6 +1,7 @@ 'use client'; import { Hotkey } from '@/components/hotkey'; +import { useOnboarding } from '@/components/onboarding/onboarding-context'; import { useEditorEngine } from '@/components/store/editor'; import { transKeys } from '@/i18n/keys'; import { EditorMode } from '@onlook/models'; @@ -57,7 +58,9 @@ const TOOLBAR_ITEMS = ({ t }: { t: ReturnType }) => [ export const BottomBar = observer(() => { const t = useTranslations(); const editorEngine = useEditorEngine(); + const { isActive, currentStep } = useOnboarding(); const toolbarItems = TOOLBAR_ITEMS({ t }); + const isOnboardingToolbarStep = isActive && currentStep === 1; // Ensure default state is set useEffect(() => { @@ -69,12 +72,16 @@ export const BottomBar = observer(() => { return ( { onMouseLeave={handleMouseLeave} > {/* Left sidebar with tabs */} -
+
{tabs.map((tab) => (
+ {/* Onboarding Trigger */} + + {/* Left Panel */}
{
+ + {/* Onboarding Overlay - positioned at top level to break panel constraints */} + + diff --git a/apps/web/client/src/app/project/[id]/_components/members/index.tsx b/apps/web/client/src/app/project/[id]/_components/members/index.tsx index 372803953f..c386e8923f 100644 --- a/apps/web/client/src/app/project/[id]/_components/members/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/members/index.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useOnboarding } from '@/components/onboarding/onboarding-context'; import { Button } from '@onlook/ui/button'; import { Icons } from '@onlook/ui/icons'; import { Popover, PopoverContent, PopoverTrigger } from '@onlook/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip'; +import { cn } from '@onlook/ui/utils'; import { useState, useRef, useEffect } from 'react'; import { MembersContent } from './members-content'; @@ -12,7 +14,9 @@ interface MembersProps { } export const Members = ({ onPopoverOpenChange }: MembersProps) => { + const { isActive, currentStep } = useOnboarding(); const [isOpen, setIsOpen] = useState(false); + const isOnboardingStep5 = isActive && currentStep === 4; const handleOpenChange = (open: boolean) => { setIsOpen(open); @@ -24,7 +28,14 @@ export const Members = ({ onPopoverOpenChange }: MembersProps) => { - diff --git a/apps/web/client/src/app/project/[id]/_components/onboarding-trigger.tsx b/apps/web/client/src/app/project/[id]/_components/onboarding-trigger.tsx new file mode 100644 index 0000000000..46bf7079fc --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/onboarding-trigger.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useOnboarding } from '@/components/onboarding/onboarding-context'; +import { Button } from '@onlook/ui/button'; +import { observer } from 'mobx-react-lite'; +import { useEffect, useState } from 'react'; + +export const OnboardingTrigger = observer(() => { + const { startOnboarding, isActive } = useOnboarding(); + + return ( +
+ +
+ ); +}); diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx index a72fa675eb..079e2f9196 100644 --- a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx @@ -1,6 +1,7 @@ 'use client'; import type { SendMessage } from '@/app/project/[id]/_hooks/use-chat'; +import { useOnboarding } from '@/components/onboarding/onboarding-context'; import { useEditorEngine } from '@/components/store/editor'; import { FOCUS_CHAT_INPUT_EVENT } from '@/components/store/editor/chat'; import { transKeys } from '@/i18n/keys'; @@ -18,6 +19,7 @@ import { compressImageInBrowser } from '@onlook/utility'; import { observer } from 'mobx-react-lite'; import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { validateImageLimit } from '../context-pills/helpers'; import { InputContextPills } from '../context-pills/input-context-pills'; import { Suggestions, type SuggestionsRef } from '../suggestions'; @@ -41,13 +43,17 @@ export const ChatInput = observer(({ onSendMessage, }: ChatInputProps) => { const editorEngine = useEditorEngine(); + const { isActive, currentStep } = useOnboarding(); const t = useTranslations(); const textareaRef = useRef(null); + const containerRef = useRef(null); const [isComposing, setIsComposing] = useState(false); const [actionTooltipOpen, setActionTooltipOpen] = useState(false); const [isDragging, setIsDragging] = useState(false); + const [portalPosition, setPortalPosition] = useState<{ top: number; left: number; width: number; height: number } | null>(null); const chatMode = editorEngine.state.chatMode; const [inputValue, setInputValue] = useState(''); + const isOnboardingChatStep = isActive && currentStep === 0; const lastUsageMessage = useMemo(() => messages.findLast(msg => msg.metadata?.usage), [messages]); const focusInput = () => { @@ -79,6 +85,30 @@ export const ChatInput = observer(({ return () => window.removeEventListener(FOCUS_CHAT_INPUT_EVENT, focusHandler); }, []); + // Track position for portal when in onboarding mode + useEffect(() => { + if (isOnboardingChatStep && containerRef.current) { + const updatePosition = () => { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + setPortalPosition({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + } + }; + + updatePosition(); + window.addEventListener('resize', updatePosition); + + return () => window.removeEventListener('resize', updatePosition); + } else { + setPortalPosition(null); + } + }, [isOnboardingChatStep]); + useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && suggestionRef.current?.handleEnterSelection()) { @@ -310,12 +340,22 @@ export const ChatInput = observer(({ }; - return ( + const chatInputContent = (isPortal = false) => (
{ handleDrop(e); setIsDragging(false); @@ -434,4 +474,14 @@ export const ChatInput = observer(({
); + + return ( + <> + {chatInputContent(false)} + {isOnboardingChatStep && portalPosition && typeof window !== 'undefined' && createPortal( + chatInputContent(true), + document.body + )} + + ); }); diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/index.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/index.tsx index 2d3501be27..73bdd7e3b1 100644 --- a/apps/web/client/src/app/project/[id]/_components/right-panel/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/index.tsx @@ -34,7 +34,7 @@ export const RightPanel = observer(() => { return (
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx index ccc31fbd23..a6db438e90 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx @@ -42,13 +42,13 @@ export const TopBar = observer(() => { ]; return ( -
+
-
+
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/mode-toggle.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/mode-toggle.tsx index 9a3db0e02c..3b13502d84 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/mode-toggle.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/mode-toggle.tsx @@ -36,7 +36,7 @@ export const ModeToggle = observer(() => { } return ( -
+
{ + const { isActive, currentStep } = useOnboarding(); const editorEngine = useEditorEngine(); const { deployment: previewDeployment, isDeploying: isPreviewDeploying } = useHostingType(DeploymentType.PREVIEW); const { deployment: customDeployment, isDeploying: isCustomDeploying } = useHostingType(DeploymentType.CUSTOM); @@ -15,6 +17,8 @@ export const TriggerButton = observer(() => { const isCustomCompleted = customDeployment?.status === DeploymentStatus.COMPLETED; const isPreviewFailed = previewDeployment?.status === DeploymentStatus.FAILED; const isCustomFailed = customDeployment?.status === DeploymentStatus.FAILED; + + const isOnboardingStep5 = isActive && currentStep === 4; const isCompleted = isPreviewCompleted || isCustomCompleted; const isFailed = isPreviewFailed || isCustomFailed; @@ -48,6 +52,7 @@ export const TriggerButton = observer(() => { className={cn( 'px-3 flex items-center border-[0.5px] text-xs justify-center shadow-sm h-8 rounded-md transition-all duration-300 ease-in-out', colorClasses, + isOnboardingStep5 && 'ring-1 ring-red-500 ring-offset-2 ring-offset-background shadow-[0_0_12px_rgba(239,68,68,0.6)]', )} > {icon} diff --git a/apps/web/client/src/app/project/[id]/providers.tsx b/apps/web/client/src/app/project/[id]/providers.tsx index c8d57925ec..511c1f2c60 100644 --- a/apps/web/client/src/app/project/[id]/providers.tsx +++ b/apps/web/client/src/app/project/[id]/providers.tsx @@ -2,6 +2,7 @@ import { EditorEngineProvider } from '@/components/store/editor'; import { HostingProvider } from '@/components/store/hosting'; +import { OnboardingProvider } from '@/components/onboarding/onboarding-context'; import type { Branch, Project } from '@onlook/models'; export const ProjectProviders = ({ @@ -16,7 +17,9 @@ export const ProjectProviders = ({ return ( - {children} + + {children} + ); diff --git a/apps/web/client/src/components/onboarding/onboarding-context.tsx b/apps/web/client/src/components/onboarding/onboarding-context.tsx new file mode 100644 index 0000000000..a5cca71cb1 --- /dev/null +++ b/apps/web/client/src/components/onboarding/onboarding-context.tsx @@ -0,0 +1,133 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect } from 'react'; + +export interface OnboardingStep { + id: string; + title: string; + description: string; + target: string; // CSS selector or identifier for the element to highlight +} + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + { + id: 'chat-input', + title: 'Welcome to Onlook!', + description: 'Start by typing your first message here to begin designing your project.', + target: 'chat-input' + }, + { + id: 'toolbar', + title: 'Restart & Reset', + description: 'Use the toolbar to restart your sandbox if you run into any issues.', + target: 'editor-toolbar' + }, + { + id: 'left-panel', + title: 'Assets & Resources', + description: 'Access your assets, components, and project files in the left panel.', + target: 'left-panel' + }, + { + id: 'design-mode', + title: 'Preview Mode', + description: 'Switch to design mode to see how your project looks and feels.', + target: 'design-mode-toggle' + }, + { + id: 'publish', + title: 'Share Your Work', + description: 'Publish and share your amazing creations with the world!', + target: 'publish-button' + } +]; + +interface OnboardingContextType { + currentStep: number; + isActive: boolean; + isFadingOut: boolean; + nextStep: () => void; + previousStep: () => void; + startOnboarding: () => void; + completeOnboarding: () => void; + skipStep: () => void; + setFadingOut: (value: boolean) => void; +} + +const OnboardingContext = createContext(undefined); + +export const useOnboarding = () => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; + +interface OnboardingProviderProps { + children: ReactNode; +} + +export const OnboardingProvider = ({ children }: OnboardingProviderProps) => { + const [currentStep, setCurrentStep] = useState(0); + const [isActive, setIsActive] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + + const startOnboarding = () => { + setIsActive(true); + setCurrentStep(0); + setIsFadingOut(false); + }; + + const nextStep = () => { + if (currentStep < ONBOARDING_STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + completeOnboarding(); + } + }; + + const previousStep = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const skipStep = () => { + nextStep(); + }; + + // Listen for skip events from overlay + useEffect(() => { + const handleSkip = () => { + skipStep(); + }; + + window.addEventListener('onboarding-skip', handleSkip); + return () => window.removeEventListener('onboarding-skip', handleSkip); + }, [skipStep]); + + const completeOnboarding = () => { + setIsActive(false); + setCurrentStep(0); + // Store in localStorage that onboarding has been completed + localStorage.setItem('onlook-onboarding-completed', 'true'); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/web/client/src/components/onboarding/onboarding-overlay.tsx b/apps/web/client/src/components/onboarding/onboarding-overlay.tsx new file mode 100644 index 0000000000..8b0ec758bb --- /dev/null +++ b/apps/web/client/src/components/onboarding/onboarding-overlay.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { useOnboarding } from '@/components/onboarding/onboarding-context'; +import { useEditorEngine } from '@/components/store/editor'; +import { cn } from '@onlook/ui/utils'; +import { useEffect, useState } from 'react'; + +export const OnboardingOverlay = () => { + const { currentStep, isActive, nextStep, completeOnboarding } = useOnboarding(); + const editorEngine = useEditorEngine(); + const [showGlow, setShowGlow] = useState(false); + const [showText, setShowText] = useState(false); + const [targetElement, setTargetElement] = useState(null); + const [isFadingOut, setIsFadingOut] = useState(false); + const [isFadingIn, setIsFadingIn] = useState(false); + const [blinkTrigger, setBlinkTrigger] = useState(0); + + useEffect(() => { + const stepTargets: Record = { + 0: 'chat-input', + 1: 'project-toolbar', + 2: 'left-panel', + 3: 'mode-toggle', + 4: 'top-right-actions', + }; + + if (!isActive) { + setShowGlow(false); + setShowText(false); + setTargetElement(null); + return; + } + + const targetSelector = stepTargets[currentStep]; + if (!targetSelector) { + setShowGlow(false); + setShowText(false); + setTargetElement(null); + return; + } + + const element = document.querySelector(`[data-onboarding-target="${targetSelector}"]`); + if (element) { + setTargetElement(element as HTMLElement); + const glowTimer = setTimeout(() => setShowGlow(true), 300); + const textTimer = setTimeout(() => setShowText(true), 800); + + return () => { + clearTimeout(glowTimer); + clearTimeout(textTimer); + }; + } else { + setTargetElement(null); + } + }, [isActive, currentStep]); + + // Clear any selected elements and frames when onboarding becomes active + useEffect(() => { + if (isActive) { + editorEngine.elements.clear(); + editorEngine.frames.deselectAll(); + setIsFadingOut(false); + setIsFadingIn(true); + // Set to false after animation completes + const timer = setTimeout(() => setIsFadingIn(false), 100); + return () => clearTimeout(timer); + } + }, [isActive, editorEngine]); + + // Handle fade out and completion + useEffect(() => { + if (isFadingOut) { + const timer = setTimeout(() => { + completeOnboarding(); + setIsFadingOut(false); + }, 700); // Match the fade-in duration + + return () => clearTimeout(timer); + } + }, [isFadingOut, completeOnboarding]); + + const handleFinish = () => { + setShowGlow(false); + setShowText(false); + setIsFadingOut(true); + }; + + if (!isActive || !targetElement) { + return null; + } + + const rect = targetElement.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const isToolbarStep = currentStep === 1; + const isLeftPanelStep = currentStep === 2; + const isModeToggleStep = currentStep === 3; + const isTopRightStep = currentStep === 4; + + const stepContent: Record = { + 0: "Type your first message here to begin designing your project.", + 1: "Use these tools to design and interact with your project.", + 2: "Access your layers, branding, pages, support, and more from here.", + 3: "Switch between Design and Preview modes to edit or view your project.", + 4: "Invite colleagues or publish your work to share it with the world.", + }; + + const glowConfig: Record = { + 0: { + top: rect.top - 50, + left: rect.left - 100, + zIndex: "z-[60]", + width: { primary: rect.width + 100, outer: rect.width + 300, ambient: rect.width + 400 }, + height: { primary: rect.height + 100, outer: rect.height + 200, ambient: rect.height + 300 }, + textLeft: centerX - 150, + textTop: centerY - 200, + }, + 1: { + top: rect.bottom - 80, + left: rect.left - 100, + zIndex: "z-10", + width: { primary: rect.width + 100, outer: rect.width + 300, ambient: rect.width + 400 }, + height: { primary: rect.height + 100, outer: rect.height + 200, ambient: rect.height + 300 }, + textLeft: centerX - 150, + textTop: rect.top - 120, + }, + 2: { + top: rect.top - 50, + left: rect.left - 100, + zIndex: "z-10", + width: { primary: rect.width + 100, outer: rect.width + 300, ambient: rect.width + 400 }, + height: { primary: rect.height + 100, outer: rect.height + 200, ambient: rect.height + 300 }, + textLeft: rect.right + 20, + textTop: rect.top + 80, + }, + 3: { + top: rect.top - 30, + left: rect.left - 50, + zIndex: "z-10", + width: { primary: rect.width + 100, outer: rect.width + 200, ambient: rect.width + 300 }, + height: { primary: rect.height + 60, outer: rect.height + 160, ambient: rect.height + 260 }, + textLeft: centerX - 150, + textTop: rect.bottom + 30, + }, + 4: { + top: rect.top - 30, + left: rect.left - 50, + zIndex: "z-[60]", + width: { primary: rect.width, outer: rect.width + 100, ambient: rect.width + 200 }, + height: { primary: rect.height + 60, outer: rect.height + 160, ambient: rect.height + 260 }, + textLeft: rect.right - 350, + textTop: rect.bottom + 50, + }, + }; + + const config = glowConfig[currentStep] || glowConfig[0]!; + const glowTop = config.top; + const glowLeft = config.left; + + return ( + <> + {/* Dark overlay over the rest of the interface - blocks all interactions */} +
+ + {/* High z-index transparent overlay to block ALL interactions above the dark overlay */} +
setBlinkTrigger(prev => prev + 1)} + /> + + {/* Large radial glow - positioned around target element */} +
+ + {/* Additional outer glow layer */} +
+ + {/* Extra large ambient glow */} +
+ + {/* Floating Text */} + {showText && ( +
+
+ {/* Main text */} +

+ {stepContent[currentStep]} +

+ + {/* Buttons */} +
+ {!isTopRightStep && ( + + )} + + +
+ +
+
+ )} + + + + ); +}; diff --git a/apps/web/client/src/components/onboarding/red-glow.tsx b/apps/web/client/src/components/onboarding/red-glow.tsx new file mode 100644 index 0000000000..c4e4d34bba --- /dev/null +++ b/apps/web/client/src/components/onboarding/red-glow.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { cn } from '@onlook/ui/utils'; +import { useEffect, useState } from 'react'; + +interface RedGlowProps { + children: React.ReactNode; + isActive?: boolean; + text?: string; + className?: string; + onComplete?: () => void; +} + +export const RedGlow = ({ + children, + isActive = false, + text = "Welcome to Onlook! Start by typing your first message here.", + className, + onComplete +}: RedGlowProps) => { + const [showGlow, setShowGlow] = useState(false); + const [showText, setShowText] = useState(false); + + useEffect(() => { + if (isActive) { + // Delay the glow appearance for a smooth entrance + const glowTimer = setTimeout(() => setShowGlow(true), 300); + // Delay the text appearance slightly after the glow + const textTimer = setTimeout(() => setShowText(true), 800); + + return () => { + clearTimeout(glowTimer); + clearTimeout(textTimer); + }; + } else { + setShowGlow(false); + setShowText(false); + } + }, [isActive]); + + const handleComplete = () => { + setShowGlow(false); + setShowText(false); + onComplete?.(); + }; + + if (!isActive) { + return <>{children}; + } + + return ( +
+ {/* Large Red Glow Effect - extends far beyond boundaries */} +
+ + {/* Even larger outer glow layer */} +
+ + {/* Extra large ambient glow */} +
+ + {/* Floating Text */} + {showText && ( +
+
+ {/* Text background with subtle glow */} +
+
+ + {/* Main text */} +

+ {text} +

+ + {/* Subtle animation for the text */} +
+
+
+ )} + + {/* Skip button */} + {showText && ( + + )} + + {/* The actual content */} +
+ {children} +
+
+ ); +};