UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

519 lines (463 loc) 15.4 kB
/** * @moduleName: Tour Service - In-App User Onboarding System * @version: 1.0.0 * @since: 2025-07-24 * @lastUpdated: 2025-07-24 * @projectSummary: MCP Quiz Server - Interactive tour system for user onboarding with spotlight effects and progressive disclosure * @techStack: TypeScript, DOM API, LocalStorage, Component Architecture * @dependency: Component system, localStorage * @interModuleDependency: TourModal, existing UI components * @requirementsTraceability: * {@link Requirements.REQ_UI_005} (Interactive Tutorial & Onboarding System) * @briefDescription: Service managing interactive tours with spotlight highlighting, step navigation, progress tracking, and persistent completion state * @methods: startTour, nextStep, previousStep, skipTour, completeTour, highlightElement * @contributors: Full Stack Wizard Mega Architect, GitHub Copilot * @examples: TourService.getInstance().startTour('welcome'); // Start welcome tour * @vulnerabilitiesAssessment: DOM element validation prevents errors, localStorage prevents tour spam, proper cleanup prevents memory leaks */ export interface TourStep { id: string; title: string; content: string; target?: string; // CSS selector for element to highlight position: 'top' | 'bottom' | 'left' | 'right' | 'center'; showSkip: boolean; showPrevious: boolean; showNext: boolean; action?: () => void; // Optional action to perform video?: string; // Optional video URL from Playwright demos } export interface Tour { id: string; name: string; description: string; steps: TourStep[]; autoStart: boolean; showOnFirstVisit: boolean; } export interface TourState { isActive: boolean; currentTour: string | null; currentStep: number; totalSteps: number; canGoBack: boolean; canGoNext: boolean; canSkip: boolean; } export class TourService { private static instance: TourService; private tours: Map<string, Tour> = new Map(); private currentTour: Tour | null = null; private currentStepIndex: number = 0; private tourModal: any = null; // Will be set by TourModal component private overlay: HTMLElement | null = null; private spotlight: HTMLElement | null = null; private callbacks: Array<(state: TourState) => void> = []; private constructor() { this.initializeTours(); this.createOverlayElements(); } /** * @description Gets singleton instance of TourService * @returns {TourService} Singleton instance */ public static getInstance(): TourService { if (!TourService.instance) { TourService.instance = new TourService(); } return TourService.instance; } /** * @description Initializes default tour configurations */ private initializeTours(): void { // Welcome Tour - First time user experience this.registerTour({ id: 'welcome', name: 'Welcome to Quiz Server', description: 'Quick introduction to the quiz application', autoStart: true, showOnFirstVisit: true, steps: [ { id: 'welcome', title: '🎉 Welcome to Quiz Server!', content: 'Take interactive quizzes with real-time feedback. This quick tour will show you the key features.', position: 'center', showSkip: true, showPrevious: false, showNext: true, video: '/demos/welcome-overview.webm', }, { id: 'quiz-list', title: '📋 Quiz Library', content: 'Browse available quizzes by category and difficulty. Click any quiz to start!', target: '#quiz-list', position: 'right', showSkip: true, showPrevious: true, showNext: true, }, { id: 'settings', title: '⚙️ Customize Experience', content: 'Access settings to personalize your quiz experience - themes, timer, navigation preferences.', target: '[data-tour="settings-button"]', position: 'bottom', showSkip: true, showPrevious: true, showNext: true, action: () => { // Briefly highlight settings button const settingsBtn = document.querySelector('[data-tour="settings-button"]'); settingsBtn?.classList.add('tour-pulse'); setTimeout(() => settingsBtn?.classList.remove('tour-pulse'), 2000); }, }, { id: 'timer', title: '⏱️ Quiz Timer', content: 'Enable the timer for timed quizzes. Perfect for practice sessions and competitive challenges.', target: '#timer-toggle', position: 'left', showSkip: true, showPrevious: true, showNext: true, }, { id: 'ready', title: "🚀 You're All Set!", content: 'Ready to test your knowledge? Pick a quiz from the sidebar and start learning!', position: 'center', showSkip: false, showPrevious: true, showNext: false, }, ], }); // Quick Features Tour - For returning users this.registerTour({ id: 'features', name: 'Key Features Tour', description: 'Overview of main application features', autoStart: false, showOnFirstVisit: false, steps: [ { id: 'navigation', title: '🧭 Smart Navigation', content: 'Use Previous/Next buttons or keyboard arrows to navigate through questions.', target: '.nav-buttons', position: 'bottom', showSkip: true, showPrevious: false, showNext: true, }, { id: 'view-modes', title: '👁️ View Modes', content: 'Switch between single question focus or see all questions at once.', target: '[data-tour="view-toggle"]', position: 'top', showSkip: true, showPrevious: true, showNext: false, }, ], }); } /** * @description Creates overlay elements for spotlight effects */ private createOverlayElements(): void { // Create tour overlay this.overlay = document.createElement('div'); this.overlay.id = 'tour-overlay'; this.overlay.className = 'tour-overlay hidden'; // Create spotlight element this.spotlight = document.createElement('div'); this.spotlight.id = 'tour-spotlight'; this.spotlight.className = 'tour-spotlight'; document.body.appendChild(this.overlay); document.body.appendChild(this.spotlight); } /** * @description Registers a new tour configuration * @param {Tour} tour Tour configuration object */ public registerTour(tour: Tour): void { this.tours.set(tour.id, tour); } /** * @description Starts a tour by ID * @param {string} tourId Tour identifier * @returns {boolean} True if tour started successfully */ public startTour(tourId: string): boolean { // Don't start tours if disabled if (this.isToursDisabled()) { return false; } const tour = this.tours.get(tourId); if (!tour || this.hasCompletedTour(tourId)) { return false; } this.currentTour = tour; this.currentStepIndex = 0; this.showStep(0); this.notifySubscribers(); return true; } /** * @description Shows a specific step in the current tour * @param {number} stepIndex Step index to show */ private showStep(stepIndex: number): void { if (!this.currentTour || stepIndex < 0 || stepIndex >= this.currentTour.steps.length) { return; } const step = this.currentTour.steps[stepIndex]; this.currentStepIndex = stepIndex; // Show overlay this.overlay?.classList.remove('hidden'); // Handle spotlight if (step.target) { this.highlightElement(step.target); } else { this.hideSpotlight(); } // Execute step action if (step.action) { step.action(); } // Update modal content (will be handled by TourModal component) if (this.tourModal) { this.tourModal.showStep(step, this.getTourState()); } } /** * @description Highlights a DOM element with spotlight effect * @param {string} selector CSS selector for element to highlight */ private highlightElement(selector: string): void { const element = document.querySelector(selector) as HTMLElement; if (!element || !this.spotlight) return; const rect = element.getBoundingClientRect(); const padding = 8; this.spotlight.style.top = `${rect.top - padding}px`; this.spotlight.style.left = `${rect.left - padding}px`; this.spotlight.style.width = `${rect.width + padding * 2}px`; this.spotlight.style.height = `${rect.height + padding * 2}px`; this.spotlight.classList.remove('hidden'); // Add highlight class to element element.classList.add('tour-highlighted'); // Remove highlight after step transition setTimeout(() => { element.classList.remove('tour-highlighted'); }, 3000); } /** * @description Hides the spotlight effect */ private hideSpotlight(): void { this.spotlight?.classList.add('hidden'); } /** * @description Advances to the next tour step * @returns {boolean} True if advanced successfully */ public nextStep(): boolean { if (!this.currentTour || this.currentStepIndex >= this.currentTour.steps.length - 1) { this.completeTour(); return false; } this.showStep(this.currentStepIndex + 1); this.notifySubscribers(); return true; } /** * @description Goes back to the previous tour step * @returns {boolean} True if went back successfully */ public previousStep(): boolean { if (!this.currentTour || this.currentStepIndex <= 0) { return false; } this.showStep(this.currentStepIndex - 1); this.notifySubscribers(); return true; } /** * @description Skips the current tour */ public skipTour(): void { this.endTour(false); } /** * @description Completes the current tour */ public completeTour(): void { this.endTour(true); } /** * @description Ends the current tour * @param {boolean} completed Whether tour was completed or skipped */ private endTour(completed: boolean): void { if (!this.currentTour) return; // Save completion state if (completed) { this.markTourAsCompleted(this.currentTour.id); } // Hide UI elements this.overlay?.classList.add('hidden'); this.hideSpotlight(); // Clear state this.currentTour = null; this.currentStepIndex = 0; // Close modal if (this.tourModal) { this.tourModal.hide(); } this.notifySubscribers(); } /** * @description Checks if user should see tour on first visit * @returns {boolean} True if should show welcome tour */ public shouldShowWelcomeTour(): boolean { // Check for programmatic tour disable flag if (this.isToursDisabled()) { return false; } return !this.hasCompletedTour('welcome') && !this.hasSeenApp(); } /** * @description Checks if tours are disabled via environment or programmatic flag * @returns {boolean} True if tours should be disabled */ private isToursDisabled(): boolean { // Check localStorage flag for programmatic disable if (localStorage.getItem('disable-tours') === 'true') { return true; } // Check for testing environment indicators if ( typeof window !== 'undefined' && ((window as any).__PLAYWRIGHT__ || (window as any).__TEST_MODE__ || navigator.webdriver || (window as any).Cypress) ) { return true; } return false; } /** * @description Checks if tour has been completed * @param {string} tourId Tour identifier * @returns {boolean} True if completed */ private hasCompletedTour(tourId: string): boolean { const completed = localStorage.getItem(`tour-completed-${tourId}`); return completed === 'true'; } /** * @description Marks tour as completed in localStorage * @param {string} tourId Tour identifier */ private markTourAsCompleted(tourId: string): void { localStorage.setItem(`tour-completed-${tourId}`, 'true'); localStorage.setItem('app-first-visit', 'false'); } /** * @description Checks if user has seen the app before * @returns {boolean} True if not first visit */ private hasSeenApp(): boolean { return localStorage.getItem('app-first-visit') === 'false'; } /** * @description Gets current tour state * @returns {TourState} Current state object */ public getTourState(): TourState { if (!this.currentTour) { return { isActive: false, currentTour: null, currentStep: 0, totalSteps: 0, canGoBack: false, canGoNext: false, canSkip: false, }; } const step = this.currentTour.steps[this.currentStepIndex]; return { isActive: true, currentTour: this.currentTour.id, currentStep: this.currentStepIndex + 1, totalSteps: this.currentTour.steps.length, canGoBack: step.showPrevious && this.currentStepIndex > 0, canGoNext: step.showNext && this.currentStepIndex < this.currentTour.steps.length - 1, canSkip: step.showSkip, }; } /** * @description Subscribes to tour state changes * @param {function} callback Function to call on state change * @returns {function} Unsubscribe function */ public subscribe(callback: (state: TourState) => void): () => void { this.callbacks.push(callback); return () => { const index = this.callbacks.indexOf(callback); if (index > -1) { this.callbacks.splice(index, 1); } }; } /** * @description Notifies all subscribers of state changes */ private notifySubscribers(): void { const state = this.getTourState(); this.callbacks.forEach(callback => { try { callback(state); } catch (error) { console.error('Error in tour state callback:', error); } }); } /** * @description Sets the tour modal component reference * @param {any} modal TourModal component instance */ public setTourModal(modal: any): void { this.tourModal = modal; } /** * @description Gets list of available tours * @returns {Tour[]} Array of available tours */ public getAvailableTours(): Tour[] { return Array.from(this.tours.values()); } /** * @description Resets tour completion state (for testing) */ public resetTourProgress(): void { this.tours.forEach((_, tourId) => { localStorage.removeItem(`tour-completed-${tourId}`); }); localStorage.removeItem('app-first-visit'); } } // Export singleton instance export const tourService = TourService.getInstance();