From a8ee0b1541988fb1f0a48e4dfaf7b8f9f29e4e2c Mon Sep 17 00:00:00 2001 From: Muhammad Zayyad Mukhtar <95658387+El-swaggerito@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:39:01 +0100 Subject: [PATCH] feat(onboarding): add interactive tour for new users Add onboarding tour component with step-by-step guidance for key app features. Introduce store to manage tour state and persistence across sessions. Add data-tour attributes to target elements for tour positioning. Automatically start tour for new users after a short delay. --- src/components/ClientProviders.tsx | 32 +++- src/components/MobileBottomNavigation.tsx | 1 + src/components/OnboardingTour.tsx | 208 +++++++++++++++++++++ src/components/PropertySearch.tsx | 1 + src/components/WalletConnector.tsx | 1 + src/components/forms/PurchaseTokenForm.tsx | 6 +- src/components/homepage/HeroSection.tsx | 1 + src/store/onboardingStore.ts | 51 +++++ 8 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 src/components/OnboardingTour.tsx create mode 100644 src/store/onboardingStore.ts diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx index 811b5928..7fe1c38c 100644 --- a/src/components/ClientProviders.tsx +++ b/src/components/ClientProviders.tsx @@ -11,6 +11,9 @@ import { LoadingProgressBar } from "@/components/LoadingProgressBar"; import "@/lib/i18n"; import dynamic from "next/dynamic"; +import { useOnboardingStore } from "@/store/onboardingStore"; +import { useEffect } from "react"; + interface ClientProvidersProps { children: React.ReactNode; } @@ -35,20 +38,27 @@ const MobileBottomNavigation = dynamic( () => import("@/components/MobileBottomNavigation").then((m) => m.MobileBottomNavigation), { ssr: false } ); +const OnboardingTour = dynamic( + () => import("@/components/OnboardingTour").then((m) => m.OnboardingTour), + { ssr: false } +); export function ClientProviders({ children }: ClientProvidersProps) { + const { startOnboarding, hasCompletedOnboarding } = useOnboardingStore(); + + useEffect(() => { + // Automatically start onboarding for new users after a short delay + const timer = setTimeout(() => { + if (!hasCompletedOnboarding) { + startOnboarding(); + } + }, 2000); + + return () => clearTimeout(timer); + }, [hasCompletedOnboarding, startOnboarding]); + return ( - - - - {children} - - - - - - @@ -59,7 +69,9 @@ export function ClientProviders({ children }: ClientProvidersProps) { + + diff --git a/src/components/MobileBottomNavigation.tsx b/src/components/MobileBottomNavigation.tsx index 6b453acc..9cebdc72 100644 --- a/src/components/MobileBottomNavigation.tsx +++ b/src/components/MobileBottomNavigation.tsx @@ -73,6 +73,7 @@ export const MobileBottomNavigation: React.FC = () => { , + }, + { + id: 'wallet', + title: 'Connect Your Wallet', + description: 'Connect your crypto wallet to start browsing and investing in properties.', + target: '[data-tour="wallet-connector"]', + icon: , + }, + { + id: 'browse', + title: 'Browse Properties', + description: 'Explore high-yield real estate opportunities across multiple chains.', + target: '[data-tour="browse-properties"]', + icon: , + }, + { + id: 'purchase', + title: 'Purchase Tokens', + description: 'Buy fractional tokens of real estate assets and start earning yield immediately.', + target: '[data-tour="purchase-form"]', + icon: , + }, + { + id: 'portfolio', + title: 'Track Your Portfolio', + description: 'Monitor your investments, earnings, and yield in one place.', + target: '[data-tour="portfolio-link"]', + icon: , + }, +]; + +export const OnboardingTour: React.FC = () => { + const { + isActive, + currentStep, + nextStep, + prevStep, + stopOnboarding, + completeOnboarding + } = useOnboardingStore(); + + const [targetRect, setTargetRect] = useState(null); + const portalRef = useRef(null); + + const step = steps[currentStep]; + + useEffect(() => { + if (!isActive || !step.target) { + setTargetRect(null); + return; + } + + const updateRect = () => { + const element = document.querySelector(step.target!); + if (element) { + setTargetRect(element.getBoundingClientRect()); + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + setTargetRect(null); + } + }; + + updateRect(); + window.addEventListener('resize', updateRect); + window.addEventListener('scroll', updateRect); + + return () => { + window.removeEventListener('resize', updateRect); + window.removeEventListener('scroll', updateRect); + }; + }, [isActive, step]); + + if (!isActive) return null; + + const isLastStep = currentStep === steps.length - 1; + + return ( + + {/* Backdrop with hole */} + + {isActive && ( + + )} + + + {/* Tour Card */} + + window.innerHeight - 200 ? 'auto' : targetRect.bottom + 20, + bottom: targetRect.bottom + 20 > window.innerHeight - 200 ? window.innerHeight - targetRect.top + 20 : 'auto', + left: Math.max(20, Math.min(window.innerWidth - 380, targetRect.left + (targetRect.width / 2) - 192)), + } : {}} + > + + + + + + + {step.icon} + + + + {step.title} + + + Step {currentStep + 1} of {steps.length} + + + + + + {step.description} + + + + + Skip + + + + {currentStep > 0 && ( + + + Back + + )} + + + {isLastStep ? 'Finish' : 'Next'} + {!isLastStep && } + + + + + {/* Progress bar */} + + + + + + + ); +}; diff --git a/src/components/PropertySearch.tsx b/src/components/PropertySearch.tsx index c2e5e4f3..0fa2692d 100644 --- a/src/components/PropertySearch.tsx +++ b/src/components/PropertySearch.tsx @@ -141,6 +141,7 @@ export const PropertySearch: React.FC = ({ onFocus={handleFocus} onKeyDown={handleKeyDown} placeholder={placeholder} + data-tour="property-search" className="w-full pl-12 pr-12 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500" /> diff --git a/src/components/WalletConnector.tsx b/src/components/WalletConnector.tsx index d615895f..5898f86b 100644 --- a/src/components/WalletConnector.tsx +++ b/src/components/WalletConnector.tsx @@ -126,6 +126,7 @@ export const WalletConnector: React.FC = () => { setIsModalOpen(true)} disabled={isConnecting} + data-tour="wallet-connector" className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2" > {isConnecting ? ( diff --git a/src/components/forms/PurchaseTokenForm.tsx b/src/components/forms/PurchaseTokenForm.tsx index d2d67451..8a328365 100644 --- a/src/components/forms/PurchaseTokenForm.tsx +++ b/src/components/forms/PurchaseTokenForm.tsx @@ -35,7 +35,11 @@ export function PurchaseTokenForm({ propertyId, propertyName, onSubmit }: Purcha return ( - + Purchase Tokens Purchase tokens for {propertyName} with on-chain validation and an enforced approval step. diff --git a/src/components/homepage/HeroSection.tsx b/src/components/homepage/HeroSection.tsx index dd32f45a..cdb7ef62 100644 --- a/src/components/homepage/HeroSection.tsx +++ b/src/components/homepage/HeroSection.tsx @@ -16,6 +16,7 @@ export function HeroSection() { void; + stopOnboarding: () => void; + nextStep: () => void; + prevStep: () => void; + completeOnboarding: () => void; + resetOnboarding: () => void; +} + +const initialState: OnboardingState = { + isActive: false, + currentStep: 0, + hasCompletedOnboarding: false, + isLoading: false, + error: null, + lastUpdated: null, +}; + +export const useOnboardingStore = createBaseStore( + initialState, + (set, get) => ({ + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + clearError: () => set({ error: null }), + setLastUpdated: (timestamp) => set({ lastUpdated: timestamp }), + reset: () => set(initialState), + + startOnboarding: () => { + if (!get().hasCompletedOnboarding) { + set({ isActive: true, currentStep: 0 }); + } + }, + stopOnboarding: () => set({ isActive: false }), + nextStep: () => set((state: OnboardingState) => ({ currentStep: state.currentStep + 1 })), + prevStep: () => set((state: OnboardingState) => ({ currentStep: Math.max(0, state.currentStep - 1) })), + completeOnboarding: () => set({ isActive: false, hasCompletedOnboarding: true, currentStep: 0 }), + resetOnboarding: () => set({ hasCompletedOnboarding: false, currentStep: 0 }), + }), + { + persist: true, + name: 'propchain-onboarding', + } +);
+ Step {currentStep + 1} of {steps.length} +
+ {step.description} +
Purchase tokens for {propertyName} with on-chain validation and an enforced approval step.