From ece370c73b267af05cc4479e1ccf6c1c9f313a0d Mon Sep 17 00:00:00 2001 From: Sam-Otuonye Royal Date: Wed, 5 Aug 2026 10:22:58 +0100 Subject: [PATCH 1/3] feat: integrate react-joyride for onboarding tours and enhance dashboard navigation with data attributes --- package.json | 1 + src/app/dashboard/announcements/page.tsx | 6 +- src/app/dashboard/cgpa/page.tsx | 2 +- src/app/dashboard/layout.tsx | 15 +- src/app/dashboard/materials/page.tsx | 4 +- src/app/dashboard/notifications/page.tsx | 2 +- src/app/dashboard/profile/page.tsx | 24 +- src/app/dashboard/settings/page.tsx | 4 +- src/app/dashboard/subscription/page.tsx | 2 +- src/app/dashboard/vendors/analytics/page.tsx | 4 +- src/app/dashboard/vendors/page.tsx | 2 +- .../dashboard/DashboardQuickActions.tsx | 2 +- .../dashboard/DashboardQuickStats.tsx | 2 +- .../dashboard/DashboardWelcomeHeader.tsx | 2 +- src/components/dashboard/dashboard-toggle.tsx | 2 +- .../dashboard/mobile-bottom-nav.tsx | 10 +- .../dashboard/mobile-header-menu.tsx | 67 +++++ src/components/dashboard/vendor-dashboard.tsx | 4 +- src/components/tour/onboarding-tour.tsx | 251 ++++++++++++++++++ src/components/tour/tour-help-button.tsx | 38 +++ src/components/tour/tour-steps.ts | 182 +++++++++++++ src/components/tour/tour-store.ts | 41 +++ .../vendors/analytics-dashboard.tsx | 17 +- .../vendors/vendor-mobile-bottom-nav.tsx | 2 +- src/components/vendors/vendor-sidebar.tsx | 2 +- 25 files changed, 648 insertions(+), 40 deletions(-) create mode 100644 src/components/dashboard/mobile-header-menu.tsx create mode 100644 src/components/tour/onboarding-tour.tsx create mode 100644 src/components/tour/tour-help-button.tsx create mode 100644 src/components/tour/tour-steps.ts create mode 100644 src/components/tour/tour-store.ts diff --git a/package.json b/package.json index 5c47c56..43d23cf 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "pdf-lib": "^1.17.1", "radix-ui": "^1.4.3", "react": "^19.2.3", + "react-joyride": "^3.2.0", "react-dom": "19.2.3", "react-hook-form": "^7.72.0", "react-pdf": "^10.3.0", diff --git a/src/app/dashboard/announcements/page.tsx b/src/app/dashboard/announcements/page.tsx index c533bfe..ddaf1e7 100644 --- a/src/app/dashboard/announcements/page.tsx +++ b/src/app/dashboard/announcements/page.tsx @@ -28,7 +28,7 @@ export default async function AnnouncementsPage() {
-

Announcements

+

Announcements

{isAdmin && (
- +
+ +
); diff --git a/src/app/dashboard/cgpa/page.tsx b/src/app/dashboard/cgpa/page.tsx index d094bed..cfdd304 100644 --- a/src/app/dashboard/cgpa/page.tsx +++ b/src/app/dashboard/cgpa/page.tsx @@ -66,7 +66,7 @@ export default async function CGPAPage() { Export */} - + + + + + + + Profile + + + + + + Settings + + + + start(isVendorViewRef.current ? 'vendor' : 'student')}> + + Replay Tour + + + + ); +} diff --git a/src/components/dashboard/vendor-dashboard.tsx b/src/components/dashboard/vendor-dashboard.tsx index bf217df..429b1c8 100644 --- a/src/components/dashboard/vendor-dashboard.tsx +++ b/src/components/dashboard/vendor-dashboard.tsx @@ -86,7 +86,7 @@ export default function VendorDashboard({ profile, vendor }: VendorDashboardProp return (
{/* Welcome Section */} -
+

Welcome back, {profile.full_name}!

Here's what's happening with your business in the last 30 days @@ -128,7 +128,7 @@ export default function VendorDashboard({ profile, vendor }: VendorDashboardProp )} {/* Stats Overview */} -

+
diff --git a/src/components/tour/onboarding-tour.tsx b/src/components/tour/onboarding-tour.tsx new file mode 100644 index 0000000..ee848df --- /dev/null +++ b/src/components/tour/onboarding-tour.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { + Joyride, + ACTIONS, + EVENTS, + STATUS, + type EventData, + type Step, +} from 'react-joyride'; +import { useTourStore } from './tour-store'; +import { + studentTourSteps, + vendorTourSteps, + type TourKind, + type TourStep, +} from './tour-steps'; + + +const STORAGE_PREFIX = 'unihub_tour_'; +const STORAGE_DONE = 'done'; + +function storageKey(tour: TourKind, userId: string) { + return `${STORAGE_PREFIX}${tour}_${userId}`; +} + +function hasSeenTour(tour: TourKind, userId: string) { + if (typeof window === 'undefined') return true; + try { + return localStorage.getItem(storageKey(tour, userId)) === STORAGE_DONE; + } catch { + return false; + } +} + +function markTourSeen(tour: TourKind, userId: string) { + try { + localStorage.setItem(storageKey(tour, userId), STORAGE_DONE); + } catch { + // ignore + } +} + + + +interface OnboardingTourProps { + isVendorView: boolean; + hasToggle: boolean; + userId: string; +} + +export function OnboardingTour({ isVendorView, hasToggle, userId }: OnboardingTourProps) { + const router = useRouter(); + const pathname = usePathname(); + + + const run = useTourStore((s) => s.run); + const tour = useTourStore((s) => s.tour); + const stepIndex = useTourStore((s) => s.stepIndex); + const pendingRoute = useTourStore((s) => s.pendingRoute); + const pendingIndex = useTourStore((s) => s.pendingIndex); + const session = useTourStore((s) => s.session); + const start = useTourStore((s) => s.start); + const stop = useTourStore((s) => s.stop); + const setStepIndex = useTourStore((s) => s.setStepIndex); + const setPending = useTourStore((s) => s.setPending); + const clearPending = useTourStore((s) => s.clearPending); + + const steps = useMemo( + () => (tour === 'vendor' ? vendorTourSteps(hasToggle) : studentTourSteps), + [tour, hasToggle], + ); + + const stepsRef = useRef(steps); + useEffect(() => { stepsRef.current = steps; }); + const pathnameRef = useRef(pathname); + useEffect(() => { pathnameRef.current = pathname; }); + + // Auto-start the appropriate tour on first visit. + useEffect(() => { + if (run) return; + const kind: TourKind = isVendorView ? 'vendor' : 'student'; + if (hasSeenTour(kind, userId)) return; + + // If the first step points to a different route, start immediately so + // navigation effect will redirect to the step route. If the first step + // targets the current route, wait for the target DOM node to exist + // (hydration/render) before starting the tour, falling back after timeout. + const first = stepsRef.current[0]; + + let cancelled = false; + + async function waitForAndStart() { + if (first?.route && first.route !== pathnameRef.current) { + start(kind); + return; + } + + const maxWait = 3000; // ms + const interval = 100; // ms + const deadline = Date.now() + maxWait; + const target = first?.target; + + const resolveTarget = () => { + try { + if (typeof target === 'function') { + return (target as any)(); + } + if (typeof target === 'string') { + return document.querySelector(target as string); + } + return null; + } catch { + return null; + } + }; + + while (!cancelled && Date.now() < deadline) { + const el = resolveTarget(); + if (el) { + start(kind); + return; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, interval)); + } + + // Fallback: start anyway if target never appeared. + if (!cancelled) start(kind); + } + + waitForAndStart(); + + return () => { + cancelled = true; + }; + }, [isVendorView, userId, run, start]); + + // Navigate to the first step's page when the tour starts elsewhere. + useEffect(() => { + if (!run) return; + const first = stepsRef.current[0]; + if (first?.route && first.route !== pathnameRef.current) { + router.push(first.route); + } + }, [run, router]); + + // After navigating to a pending step's page, advance the tour. + useEffect(() => { + if (!run || pendingIndex === null || !pendingRoute) return; + if (pathname !== pendingRoute) return; + const timer = window.setTimeout(() => { + setStepIndex(pendingIndex); + clearPending(); + }, 300); + return () => window.clearTimeout(timer); + }, [run, pathname, pendingRoute, pendingIndex, setStepIndex, clearPending]); + + // Follow manual navigation while the tour is running. + const wasRunningRef = useRef(false); + useEffect(() => { + if (!run) { + wasRunningRef.current = false; + return; + } + if (!wasRunningRef.current) { + wasRunningRef.current = true; + return; + } + if (pendingIndex !== null) return; + const current = stepsRef.current[stepIndex]; + if (!current?.route || current.route === pathname) return; + const idx = stepsRef.current.findIndex((s) => s.route === pathname); + if (idx !== -1 && idx !== stepIndex) setStepIndex(idx); + }, [run, pathname, stepIndex, pendingIndex, setStepIndex]); + + const handleEvent = useCallback( + (data: EventData) => { + if (!run) return; + const { type, action, index, status } = data; + + if ( + type === EVENTS.TOUR_END && + (status === STATUS.FINISHED || status === STATUS.SKIPPED) + ) { + if (tour) markTourSeen(tour, userId); + stop(); + return; + } + + if (type === EVENTS.STEP_AFTER) { + const delta = action === ACTIONS.PREV ? -1 : 1; + const nextIndex = index + delta; + if (nextIndex < 0 || nextIndex >= stepsRef.current.length) { + if (tour) markTourSeen(tour, userId); + stop(); + return; + } + const nextStep = stepsRef.current[nextIndex]; + if (nextStep.route && nextStep.route !== pathnameRef.current) { + setPending(nextStep.route, nextIndex); + router.push(nextStep.route); + } else { + setStepIndex(nextIndex); + } + } + }, + [run, tour, userId, setPending, setStepIndex, stop, router], + ); + + return ( + + ); +} diff --git a/src/components/tour/tour-help-button.tsx b/src/components/tour/tour-help-button.tsx new file mode 100644 index 0000000..52efd8d --- /dev/null +++ b/src/components/tour/tour-help-button.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { HelpCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTourStore } from './tour-store'; + +interface TourHelpButtonProps { + hasVendor: boolean; + isVendorAccount: boolean; + className?: string; +} + +export function TourHelpButton({ hasVendor, isVendorAccount, className }: TourHelpButtonProps) { + const start = useTourStore((s) => s.start); + const isVendorViewRef = useRef(false); + + useEffect(() => { + const isStudent = document.cookie + .split('; ') + .find((r) => r.startsWith('isStudent=')) + ?.split('=')[1] !== 'false'; + isVendorViewRef.current = isVendorAccount || (hasVendor && !isStudent); + }, [hasVendor, isVendorAccount]); + + return ( + + ); +} diff --git a/src/components/tour/tour-steps.ts b/src/components/tour/tour-steps.ts new file mode 100644 index 0000000..0345d85 --- /dev/null +++ b/src/components/tour/tour-steps.ts @@ -0,0 +1,182 @@ +import type { Step } from 'react-joyride'; + +export type TourKind = 'student' | 'vendor'; +export type TourStep = Step & { route: string }; + +export function navTarget(): HTMLElement | null { + if (typeof window === 'undefined') return null; + const isDesktop = window.matchMedia('(min-width: 768px)').matches; + if (isDesktop) { + // Prefer the vendor sidebar when we can detect the vendor dashboard. + // Vendor sidebar includes a header/footer text like "Vendor Dashboard" or + // "Vendor Account"; detect those markers first and return the sidebar + // element when present. + let sidebar = document.querySelector('[data-slot="sidebar"]'); + if (sidebar) { + try { + const headerText = (sidebar.querySelector('h1, p, a') as HTMLElement | null) + ?.textContent?.trim() ?? ''; + const footerText = (sidebar.querySelector('footer, .sidebar-footer, p') as HTMLElement | null) + ?.textContent?.trim() ?? ''; + + const isVendorSidebar = /Vendor Dashboard|Vendor Account/i.test(headerText + ' ' + footerText); + if (isVendorSidebar){ + sidebar = document.querySelector('[data-tour="vendor-sidebar"]'); + return sidebar as HTMLElement; + + } + } catch { + // ignore and fall back to default + } + } + + return sidebar as HTMLElement | null; + } + const nodes = document.querySelectorAll('[data-tour="mobile-nav"]'); + for (const node of nodes) { + if (node.offsetParent !== null) return node; + } + return nodes[0] ?? null; +} + +export const studentTourSteps: TourStep[] = [ + { + target: '[data-tour="student-welcome"]', + content: 'This quick tour will show you around your dashboard. Click Next to begin.', + title: 'Welcome to UniHub', + route: '/dashboard', + placement: 'bottom', + }, + { + target: '[data-tour="student-stats"]', + content: 'Track your CGPA, browse study materials, and explore verified student vendors — all in one place.', + title: 'Your academic snapshot', + route: '/dashboard', + placement: 'top', + }, + { + target: '[data-tour="student-actions"]', + content: 'Jump straight to the materials library, add a semester, find vendors, or read the latest announcements.', + title: 'Quick Actions', + route: '/dashboard', + placement: 'top', + }, + { + target: navTarget, + content: 'Use the menu to navigate every section. On mobile, use the bottom bar.', + title: 'Navigate anywhere', + route: '/dashboard', + placement: 'auto', + }, + { + target: '[data-tour="page-materials"]', + content: 'Access lecture notes, past questions, and study materials uploaded by verified students.', + title: 'Materials Library', + route: '/dashboard/materials', + placement: 'bottom', + }, + { + target: '[data-tour="page-cgpa"]', + content: 'Add your semester results and instantly calculate your cumulative GPA.', + title: 'CGPA Calculator', + route: '/dashboard/cgpa', + placement: 'bottom', + }, + { + target: '[data-tour="page-profile"]', + content: 'Update your personal details, manage your matric number, and view your subscription status.', + title: 'Your Profile', + route: '/dashboard/profile', + placement: 'bottom', + }, + { + target: '[data-tour="page-vendors"]', + content: 'Connect with verified service providers on campus — from food to fashion.', + title: 'Vendors Marketplace', + route: '/dashboard/vendors', + placement: 'bottom', + }, + { + target: '[data-tour="page-announcements"]', + content: 'Stay updated with department and university announcements.', + title: 'Announcements', + route: '/dashboard/announcements', + placement: 'bottom', + }, + { + target: '[data-tour="page-settings"]', + content: 'Customize your appearance, notifications, and privacy preferences.', + title: 'Settings', + route: '/dashboard/settings', + placement: 'bottom', + }, +]; + +export function vendorTourSteps(includeToggle: boolean): TourStep[] { + const steps: TourStep[] = [ + { + target: '[data-tour="vendor-welcome"]', + content: 'Your vendor dashboard at a glance. Manage your business and track how it\'s performing.', + title: 'Welcome to your Vendor Dashboard', + route: '/dashboard', + placement: 'bottom', + }, + { + target: '[data-tour="vendor-stats"]', + content: 'See how many people viewed your listing, reached out, and rated your service.', + title: 'Your performance', + route: '/dashboard', + placement: 'top', + }, + ]; + + if (includeToggle) { + steps.push({ + target: '[data-tour="dashboard-toggle"]', + content: 'Switch between your Student and Vendor dashboards anytime using this toggle.', + title: 'Student ↔ Vendor', + route: '/dashboard', + placement: 'bottom', + }); + } + + steps.push( + { + target: navTarget, + content: 'Use the menu to access analytics, subscription, notifications, and settings. On mobile, use the bottom bar.', + title: 'Vendor navigation', + route: '/dashboard', + placement: 'auto', + }, + { + target: '[data-tour="page-analytics"]', + content: 'Dive into views, contacts, ratings, and conversion data over time.', + title: 'Analytics', + route: '/dashboard/vendors/analytics', + placement: 'left', + }, + { + target: '[data-tour="page-subscription"]', + content: 'Manage your subscription plan, view billing history, and upgrade or cancel.', + title: 'Subscription', + route: '/dashboard/subscription', + placement: 'bottom', + }, + { + target: '[data-tour="page-notifications"]', + content: 'Get alerts for inquiries and activity related to your business.', + title: 'Notifications', + route: '/dashboard/notifications', + placement: 'bottom', + }, + { + target: '[data-tour="page-settings"]', + content: 'Customize your appearance and manage your account.', + title: 'Settings', + route: '/dashboard/settings', + placement: 'bottom', + }, + ); + + return steps; +} diff --git a/src/components/tour/tour-store.ts b/src/components/tour/tour-store.ts new file mode 100644 index 0000000..52d2ec4 --- /dev/null +++ b/src/components/tour/tour-store.ts @@ -0,0 +1,41 @@ +'use client'; + +import { create } from 'zustand'; +import type { TourKind } from './tour-steps'; + +interface TourStore { + run: boolean; + tour: TourKind | null; + stepIndex: number; + session: number; + pendingRoute: string | null; + pendingIndex: number | null; + start: (tour: TourKind) => void; + stop: () => void; + setStepIndex: (index: number) => void; + setPending: (route: string, index: number) => void; + clearPending: () => void; +} + +export const useTourStore = create((set) => ({ + run: false, + tour: null, + stepIndex: 0, + session: 0, + pendingRoute: null, + pendingIndex: null, + start: (tour) => + set((s) => ({ + run: true, + tour, + stepIndex: 0, + session: s.session + 1, + pendingRoute: null, + pendingIndex: null, + })), + stop: () => + set({ run: false, tour: null, stepIndex: 0, pendingRoute: null, pendingIndex: null }), + setStepIndex: (index) => set({ stepIndex: index }), + setPending: (route, index) => set({ pendingRoute: route, pendingIndex: index }), + clearPending: () => set({ pendingRoute: null, pendingIndex: null }), +})); diff --git a/src/components/vendors/analytics-dashboard.tsx b/src/components/vendors/analytics-dashboard.tsx index 8136111..9477bb6 100644 --- a/src/components/vendors/analytics-dashboard.tsx +++ b/src/components/vendors/analytics-dashboard.tsx @@ -422,7 +422,20 @@ export default function AnalyticsDashboard({
{/* Conversion Rate Card */} - +{ hasBasicTier ? ( +
+ +

+ Detailed Analytics Locked +

+

+ Upgrade to Premium for daily analytics charts +

+ + + +
) : ( +
@@ -449,7 +462,7 @@ export default function AnalyticsDashboard({ />
- + )} {/* Main Chart */} diff --git a/src/components/vendors/vendor-mobile-bottom-nav.tsx b/src/components/vendors/vendor-mobile-bottom-nav.tsx index 3095586..7fa4326 100644 --- a/src/components/vendors/vendor-mobile-bottom-nav.tsx +++ b/src/components/vendors/vendor-mobile-bottom-nav.tsx @@ -62,7 +62,7 @@ export function VendorMobileBottomNav() { return ( <> -