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
This commit is contained in:
sirpiglr 2025-12-15 23:51:01 +00:00
parent 8d68667725
commit c08105ded6
3 changed files with 423 additions and 5 deletions

View file

@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Toaster />
<Router />
<TutorialProvider>
<Toaster />
<Router />
</TutorialProvider>
</AuthProvider>
</QueryClientProvider>
);

View file

@ -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<TutorialContextType | null>(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<TutorialStep[]>([]);
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 (
<TutorialContext.Provider
value={{
isActive,
currentStep,
steps,
startTutorial,
endTutorial,
nextStep,
prevStep,
skipTutorial,
hasCompletedTutorial,
}}
>
{children}
<TutorialOverlay />
</TutorialContext.Provider>
);
}
function TutorialOverlay() {
const { isActive, currentStep, steps, nextStep, prevStep, skipTutorial } = useTutorial();
const [targetRect, setTargetRect] = useState<DOMRect | null>(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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-label="Platform tutorial"
>
{/* Dark overlay with cutout */}
<div className="absolute inset-0 bg-black/80" aria-hidden="true" />
{/* Highlight target element */}
{targetRect && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute border-2 border-primary rounded-lg pointer-events-none"
style={{
top: targetRect.top - 4,
left: targetRect.left - 4,
width: targetRect.width + 8,
height: targetRect.height + 8,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 20px rgba(234, 179, 8, 0.5)",
}}
/>
)}
{/* Tooltip */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute bg-card border border-white/10 p-6 w-[360px] max-w-[90vw] shadow-2xl"
style={getTooltipPosition()}
role="alertdialog"
aria-labelledby="tutorial-title"
aria-describedby="tutorial-content"
>
{/* Progress bar */}
<div className="absolute top-0 left-0 right-0 h-1 bg-white/10">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Close button */}
<button
onClick={skipTutorial}
className="absolute top-3 right-3 text-muted-foreground hover:text-white transition-colors"
data-testid="button-tutorial-close"
>
<X className="w-4 h-4" />
</button>
{/* Step indicator */}
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-xs text-primary font-bold uppercase tracking-wider">
Step {currentStep + 1} of {steps.length}
</span>
</div>
{/* Content */}
<h3 id="tutorial-title" className="text-lg font-display text-white uppercase mb-2">{step.title}</h3>
<p id="tutorial-content" className="text-sm text-muted-foreground mb-6 leading-relaxed">{step.content}</p>
{step.action && (
<p className="text-xs text-primary/80 mb-4 italic">{step.action}</p>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={skipTutorial}
className="text-xs text-muted-foreground hover:text-white transition-colors"
data-testid="button-tutorial-skip"
>
Skip Tutorial
</button>
<div className="flex items-center gap-2">
{currentStep > 0 && (
<button
onClick={prevStep}
className="flex items-center gap-1 px-3 py-2 text-xs text-muted-foreground hover:text-white transition-colors"
data-testid="button-tutorial-prev"
>
<ChevronLeft className="w-3 h-3" /> Back
</button>
)}
<button
onClick={nextStep}
className="flex items-center gap-1 px-4 py-2 bg-primary text-background text-xs font-bold uppercase tracking-wider hover:bg-primary/90 transition-colors"
data-testid="button-tutorial-next"
>
{currentStep === steps.length - 1 ? (
<>
<CheckCircle className="w-3 h-3" /> Complete
</>
) : (
<>
Next <ChevronRight className="w-3 h-3" />
</>
)}
</button>
</div>
</div>
{/* Step dots */}
<div className="flex justify-center gap-1 mt-4">
{steps.map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentStep ? "bg-primary" : i < currentStep ? "bg-primary/50" : "bg-white/20"
}`}
/>
))}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
export const homeTutorialSteps: TutorialStep[] = [
{
id: "welcome",
title: "Welcome to AeThex",
content: "This is the Operating System for the Metaverse. Let me show you around the platform and its key features.",
position: "center",
},
{
id: "metrics",
title: "Live Ecosystem Metrics",
content: "These numbers update in real-time, showing the total number of architects, projects, and activity across the platform.",
target: "metrics-section",
position: "bottom",
},
{
id: "axiom",
title: "Axiom - The Law",
content: "Click here to learn about our dual-entity model and view the investor pitch deck with real data and charts.",
target: "axiom-card",
position: "bottom",
},
{
id: "codex",
title: "Codex - The Standard",
content: "The Foundation trains elite Metaverse Architects through gamified curriculum and verified certifications.",
target: "codex-card",
position: "bottom",
},
{
id: "aegis",
title: "Aegis - The Shield",
content: "Real-time security for virtual environments. PII scrubbing, threat detection, and protection for every line of code.",
target: "aegis-card",
position: "bottom",
},
{
id: "demos",
title: "Try It Yourself",
content: "Explore our demos: view a sample Passport credential, try the Terminal security demo, or browse the Tech Tree curriculum.",
target: "demo-section",
position: "top",
},
{
id: "complete",
title: "You're All Set!",
content: "You now know the basics of AeThex. Start exploring, or visit our Foundation to begin your journey as a Metaverse Architect.",
position: "center",
},
];
export const dashboardTutorialSteps: TutorialStep[] = [
{
id: "welcome",
title: "Your Dashboard",
content: "Welcome to your personal command center. Here you can track your progress, achievements, and activity.",
position: "center",
},
{
id: "profile",
title: "Your Profile",
content: "This shows your current level, XP, and verification status. Keep completing challenges to level up!",
target: "profile-section",
position: "right",
},
{
id: "stats",
title: "Your Stats",
content: "Track your key metrics: total XP earned, current level, and your verification status.",
target: "stats-section",
position: "bottom",
},
];
export function TutorialButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="fixed bottom-6 right-6 z-50 bg-primary text-background p-3 rounded-full shadow-lg hover:bg-primary/90 transition-colors group"
data-testid="button-start-tutorial"
>
<Sparkles className="w-5 h-5" />
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-card border border-white/10 px-3 py-1 text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
Start Tutorial
</span>
</button>
);
}

View file

@ -1,14 +1,18 @@
import { useEffect } from "react";
import { motion } from "framer-motion";
import { Link } from "wouter";
import { useQuery } from "@tanstack/react-query";
import {
Shield, FileCode, Terminal as TerminalIcon, ChevronRight, BarChart3, Network,
ExternalLink, Lock, Zap, Users, Globe, CheckCircle, ArrowRight, Star,
Award, Cpu, Building
Award, Cpu, Building, Sparkles
} from "lucide-react";
import gridBg from '@assets/generated_images/dark_subtle_digital_grid_texture.png';
import { useTutorial, homeTutorialSteps, TutorialButton } from "@/components/Tutorial";
export default function Home() {
const { startTutorial, hasCompletedTutorial, isActive } = useTutorial();
const { data: metrics } = useQuery({
queryKey: ["metrics"],
queryFn: async () => {
@ -99,6 +103,7 @@ export default function Home() {
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-16 w-full max-w-4xl"
data-tutorial="metrics-section"
>
<div className="text-center p-4 border border-white/5 bg-card/30 hover:border-primary/30 transition-colors">
<div className="text-3xl font-display font-bold text-primary">{metrics.totalProfiles}</div>
@ -130,6 +135,7 @@ export default function Home() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="group relative border border-white/10 bg-card/50 p-8 hover:border-primary/50 transition-colors duration-300 cursor-pointer overflow-hidden h-full"
data-tutorial="axiom-card"
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex justify-between items-start mb-6">
@ -152,6 +158,7 @@ export default function Home() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="group relative border border-white/10 bg-card/50 p-8 hover:border-secondary/50 transition-colors duration-300 cursor-pointer overflow-hidden h-full"
data-tutorial="codex-card"
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-secondary/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex justify-between items-start mb-6">
@ -168,7 +175,7 @@ export default function Home() {
</motion.div>
</a>
<a href="https://aethex.studio" target="_blank" rel="noopener noreferrer" className="block">
<a href="https://aethex.studio" target="_blank" rel="noopener noreferrer" className="block" data-tutorial="aegis-card">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -330,7 +337,7 @@ export default function Home() {
<p className="text-2xl font-display text-white uppercase">See It In Action</p>
</motion.div>
<div className="flex flex-wrap justify-center gap-4">
<div className="flex flex-wrap justify-center gap-4" data-tutorial="demo-section">
<Link href="/passport">
<button className="text-sm border border-white/10 px-6 py-3 text-muted-foreground hover:text-white hover:border-white/30 transition-colors" data-testid="button-sample-passport">
View Sample Passport
@ -376,6 +383,11 @@ export default function Home() {
</div>
</div>
{/* Tutorial Button */}
{!isActive && (
<TutorialButton onClick={() => startTutorial(homeTutorialSteps)} />
)}
</div>
);
}