From c08105ded6ed39326db9621fec5601a8e8933d09 Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Mon, 15 Dec 2025 23:51:01 +0000 Subject: [PATCH] Introduce an interactive tutorial for new users to guide them through the platform Integrate a new tutorial system with step-by-step guidance, tooltips, and progress tracking. Add data attributes to the home page elements for tutorial targeting and wrap the Router in a TutorialProvider. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 279f1558-c0e3-40e4-8217-be7e9f4c6eca Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: fcbfb698-a413-4f05-9e02-74eaaa3905f8 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/b984cb14-1d19-4944-922b-bc79e821ed35/279f1558-c0e3-40e4-8217-be7e9f4c6eca/eXgOAd7 Replit-Helium-Checkpoint-Created: true --- client/src/App.tsx | 7 +- client/src/components/Tutorial.tsx | 403 +++++++++++++++++++++++++++++ client/src/pages/home.tsx | 18 +- 3 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 client/src/components/Tutorial.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index efd03ec..438611a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,7 @@ import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { AuthProvider } from "@/lib/auth"; +import { TutorialProvider } from "@/components/Tutorial"; import NotFound from "@/pages/not-found"; import Home from "@/pages/home"; import Passport from "@/pages/passport"; @@ -49,8 +50,10 @@ function App() { return ( - - + + + + ); diff --git a/client/src/components/Tutorial.tsx b/client/src/components/Tutorial.tsx new file mode 100644 index 0000000..9dfcef6 --- /dev/null +++ b/client/src/components/Tutorial.tsx @@ -0,0 +1,403 @@ +import { useState, useEffect, createContext, useContext, ReactNode } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, ChevronRight, ChevronLeft, CheckCircle, Sparkles } from "lucide-react"; + +export interface TutorialStep { + id: string; + title: string; + content: string; + target?: string; + position?: "top" | "bottom" | "left" | "right" | "center"; + action?: string; +} + +interface TutorialContextType { + isActive: boolean; + currentStep: number; + steps: TutorialStep[]; + startTutorial: (steps: TutorialStep[]) => void; + endTutorial: () => void; + nextStep: () => void; + prevStep: () => void; + skipTutorial: () => void; + hasCompletedTutorial: boolean; +} + +const TutorialContext = createContext(null); + +export function useTutorial() { + const context = useContext(TutorialContext); + if (!context) { + throw new Error("useTutorial must be used within TutorialProvider"); + } + return context; +} + +export function TutorialProvider({ children }: { children: ReactNode }) { + const [isActive, setIsActive] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [steps, setSteps] = useState([]); + const [hasCompletedTutorial, setHasCompletedTutorial] = useState(false); + + useEffect(() => { + if (typeof window !== "undefined") { + setHasCompletedTutorial(localStorage.getItem("aethex_tutorial_completed") === "true"); + } + }, []); + + const startTutorial = (newSteps: TutorialStep[]) => { + setSteps(newSteps); + setCurrentStep(0); + setIsActive(true); + }; + + const endTutorial = () => { + setIsActive(false); + setCurrentStep(0); + setSteps([]); + setHasCompletedTutorial(true); + if (typeof window !== "undefined") { + localStorage.setItem("aethex_tutorial_completed", "true"); + } + }; + + const nextStep = () => { + if (currentStep < steps.length - 1) { + setCurrentStep(currentStep + 1); + } else { + endTutorial(); + } + }; + + const prevStep = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const skipTutorial = () => { + endTutorial(); + }; + + return ( + + {children} + + + ); +} + +function TutorialOverlay() { + const { isActive, currentStep, steps, nextStep, prevStep, skipTutorial } = useTutorial(); + const [targetRect, setTargetRect] = useState(null); + + const step = steps[currentStep]; + + useEffect(() => { + if (!isActive || !step?.target) { + setTargetRect(null); + return; + } + + const findTarget = () => { + const element = document.querySelector(`[data-tutorial="${step.target}"]`); + if (element) { + const rect = element.getBoundingClientRect(); + setTargetRect(rect); + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } else { + setTargetRect(null); + } + }; + + findTarget(); + const interval = setInterval(findTarget, 500); + return () => clearInterval(interval); + }, [isActive, step]); + + if (!isActive || !step) return null; + + const getTooltipPosition = () => { + const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 800; + const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 600; + const isMobile = viewportWidth < 640; + const padding = 16; + const tooltipWidth = isMobile ? Math.min(320, viewportWidth - 32) : 360; + const tooltipHeight = 200; + + if (!targetRect || step.position === "center" || isMobile) { + return { + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + maxWidth: `${viewportWidth - 32}px`, + }; + } + + let top = 0; + let left = 0; + + switch (step.position || "bottom") { + case "top": + top = targetRect.top - tooltipHeight - padding; + left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; + break; + case "bottom": + top = targetRect.bottom + padding; + left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; + break; + case "left": + top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2; + left = targetRect.left - tooltipWidth - padding; + break; + case "right": + top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2; + left = targetRect.right + padding; + break; + default: + top = targetRect.bottom + padding; + left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2; + } + + left = Math.max(padding, Math.min(left, viewportWidth - tooltipWidth - padding)); + top = Math.max(padding, Math.min(top, viewportHeight - tooltipHeight - padding)); + + return { + top: `${top}px`, + left: `${left}px`, + maxWidth: `${tooltipWidth}px`, + }; + }; + + const progress = ((currentStep + 1) / steps.length) * 100; + + return ( + + + {/* Dark overlay with cutout */} +