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
text/typescript
/**
* @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();