diff --git a/dev-server.log b/dev-server.log new file mode 100644 index 0000000..d90318d --- /dev/null +++ b/dev-server.log @@ -0,0 +1,19 @@ + +> engiportal@0.1.0 dev +> next dev + +⚠ Port 3000 is in use by an unknown process, using available port 3001 instead. +▲ Next.js 16.2.12 (Turbopack) +- Local: http://localhost:3001 +- Network: http://192.168.19.132:3001 +- Environments: .env +✓ Ready in 3.0s +⨯ Another next dev server is already running. + +- Local: http://localhost:3000 +- PID: 18336 +- Dir: C:\Users\DELL\Desktop\engiportal +- Log: .next\dev\logs\next-development.log + +Run taskkill /PID 18336 /F to stop it. +[?25h diff --git a/package.json b/package.json index 38d307e..4d30549 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "posthog-js": "^1.408.2", "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-joyride": "^3.2.0", "react-pdf": "^10.3.0", "recharts": "^3.8.1", "sonner": "^2.0.7", diff --git a/src/app/dashboard/announcements/page.tsx b/src/app/dashboard/announcements/page.tsx index 9d332d0..818de16 100644 --- a/src/app/dashboard/announcements/page.tsx +++ b/src/app/dashboard/announcements/page.tsx @@ -39,9 +39,9 @@ export default async function AnnouncementsPage() { Stay updated with department and university announcements

-

Loading announcements...

}> +
- +
); diff --git a/src/app/dashboard/cgpa/page.tsx b/src/app/dashboard/cgpa/page.tsx index de68efb..b1ab202 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 f8c4d7b..407bb5f 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/materials/materials-content.tsx b/src/components/materials/materials-content.tsx index 56afc3c..64103fc 100644 --- a/src/components/materials/materials-content.tsx +++ b/src/components/materials/materials-content.tsx @@ -94,7 +94,7 @@ export default function MaterialsContent({ } return ( -
+
{materials.map((material) => ( { }; - // Debounced search - useEffect(() => { - const timer = setTimeout(() => { - updateFilters(); - if (search.trim()) { - handleMaterialSearch(search.trim()); - } - }, 500); - - return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [search, level, semester, type]); - const updateFilters = () => { const params = new URLSearchParams(searchParams.toString()); @@ -98,6 +84,26 @@ const handleSavedMaterials = () => { router.push(`/dashboard/materials${query ? `?${query}` : ''}`); }; + // Debounced search + const isFirstRender = useRef(true); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + const timer = setTimeout(() => { + updateFilters(); + if (search.trim()) { + handleMaterialSearch(search.trim()); + } + }, 500); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, level, semester, type]); + const savedActive = searchParams.get("saved") === "true"; const hasActiveFilters = search || level !== "all" || semester !== "all" || type !== "all" || savedActive; diff --git a/src/components/tour/onboarding-tour.tsx b/src/components/tour/onboarding-tour.tsx new file mode 100644 index 0000000..3033643 --- /dev/null +++ b/src/components/tour/onboarding-tour.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } 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, + studentMobileTourSteps, + vendorMobileTourSteps, + 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 + } +} + +/** + * Tracks the `md` breakpoint used by the dashboard layout. Updates on resize / + * orientation change so the correct step set is always used. + */ +function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => + typeof window !== 'undefined' && window.matchMedia('(max-width: 767.98px)').matches, + ); + + useEffect(() => { + const mql = window.matchMedia('(max-width: 767.98px)'); + const update = () => setIsMobile(mql.matches); + update(); + mql.addEventListener('change', update); + return () => mql.removeEventListener('change', update); + }, []); + + return isMobile; +} + + + +interface OnboardingTourProps { + isVendorView: boolean; + hasToggle: boolean; + userId: string; +} + +export function OnboardingTour({ isVendorView, hasToggle, userId }: OnboardingTourProps) { + const router = useRouter(); + const pathname = usePathname(); + + const [debugLines, setDebugLines] = useState([]); + const [tick, setTick] = useState(0); + + 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 isMobile = useIsMobile(); + + const steps = useMemo(() => { + if (tour === 'vendor') { + return isMobile ? vendorMobileTourSteps() : vendorTourSteps(hasToggle); + } + return isMobile ? studentMobileTourSteps : studentTourSteps; + }, [tour, hasToggle, isMobile]); + + const stepsRef = useRef(steps); + useEffect(() => { stepsRef.current = steps; }); + const pathnameRef = useRef(pathname); + useEffect(() => { pathnameRef.current = pathname; }); + + // Guard against the steps array shrinking when the viewport crosses the + // mobile breakpoint while the tour is running. + useEffect(() => { + if (!run || stepIndex < steps.length) return; + setStepIndex(Math.max(0, steps.length - 1)); + }, [run, stepIndex, steps.length, setStepIndex]); + + // Auto-start the appropriate tour on first visit. + useEffect(() => { + if (run) return; + const kind: TourKind = isVendorView ? 'vendor' : 'student'; + const forceStart = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('tourStart'); + if (hasSeenTour(kind, userId) && !forceStart) 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 () => HTMLElement | null)(); + } + 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; + } + 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; + + const debugOn = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('tourDebug'); + + if (debugOn) { + const lifecycle = (data as { lifecycle?: string }).lifecycle ?? '-'; + setDebugLines((prev) => + [ + ...prev, + `${new Date().toISOString().slice(11, 23)} ev=${type} act=${action} idx=${index} st=${status} lc=${lifecycle}`, + ].slice(-14), + ); + } + + 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], + ); + + const debugOn = + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('tourDebug'); + + useEffect(() => { + if (!debugOn) return; + const id = window.setInterval(() => setTick((t) => t + 1), 500); + return () => window.clearInterval(id); + }, [debugOn]); + + const debugData = useMemo(() => { + if (typeof window === 'undefined') return null; + + const cur = steps[stepIndex]; + let targetRect = '-'; + if (cur?.target) { + try { + const el = + typeof cur.target === 'function' + ? (cur.target as () => HTMLElement | null)() + : typeof cur.target === 'string' + ? document.querySelector(cur.target) + : null; + if (el) { + const r = (el as HTMLElement).getBoundingClientRect(); + targetRect = `x=${Math.round(r.left)} y=${Math.round(r.top)} w=${Math.round(r.width)} h=${Math.round(r.height)}`; + } else { + targetRect = 'NOT FOUND'; + } + } catch { + targetRect = 'target err'; + } + } + + const floater = document.querySelector('.react-joyride__floater'); + let floaterRect = '-'; + if (floater) { + const r = floater.getBoundingClientRect(); + const cs = getComputedStyle(floater); + floaterRect = `x=${Math.round(r.left)} y=${Math.round(r.top)} w=${Math.round(r.width)} h=${Math.round(r.height)} pos=${cs.position} left=${cs.left} top=${cs.top} op=${cs.opacity} disp=${cs.display}`; + } + + return { + windowSize: `${window.innerWidth}x${window.innerHeight}`, + mobile: isMobile, + pathname, + run, + tour, + stepIndex, + targetRoute: cur?.route ?? '-', + targetRect, + floaterRect, + overlay: document.querySelector('.react-joyride__overlay') ? 'yes' : 'no', + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [steps, stepIndex, pathname, run, tour, isMobile, debugLines, tick]); + + return ( + <> + + {debugOn && debugData && ( +
+
win={debugData.windowSize} mobile={String(debugData.mobile)}
+
+ route={debugData.pathname} run={String(debugData.run)} tour={debugData.tour} idx={debugData.stepIndex} +
+
overlay={debugData.overlay}
+
target[{debugData.targetRoute}]: {debugData.targetRect}
+
floater: {debugData.floaterRect}
+
events:
+ {[...debugLines].reverse().map((l, i) => ( +
{l}
+ ))} +
+ )} + + ); +} 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..bb61f23 --- /dev/null +++ b/src/components/tour/tour-steps.ts @@ -0,0 +1,333 @@ +import type { Step } from 'react-joyride'; + +export type TourKind = 'student' | 'vendor'; +export type TourStep = Step & { route: string }; + +export function isDesktopView(): boolean { + return typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; +} + +/** + * The dashboard layout renders page content twice (a desktop wrapper and a + * mobile wrapper, toggled by `hidden md:block` / `md:hidden`). On mobile the + * desktop copy is `display: none`, so a plain querySelector returns a hidden + * element and the tour reports the target as not visible. Return the first + * visible instance instead. + */ +function visibleQuery(selector: string): HTMLElement | null { + if (typeof window === 'undefined') return null; + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (node.offsetParent !== null) return node; + } + return nodes[0] ?? null; +} + +function pageTarget(selector: string): () => HTMLElement | null { + return () => { + const wrapper = visibleQuery(selector); + if (!wrapper) return null; + if (isDesktopView()) return wrapper; + const compact = + wrapper.querySelector('h1, h2, h3, h4') || + wrapper.querySelector('[data-slot="card"]') || + wrapper.querySelector('a, button, [role="button"]'); + return compact ?? wrapper; + }; +} + +/** + * Returns the first visible instance of a duplicated element (the dashboard + * layout renders content twice; the hidden copy must never be targeted). + */ +function visibleTarget(selector: string): () => HTMLElement | null { + return () => visibleQuery(selector); +} + +/** + * Mobile steps target elements inside the content's own scroll container + * (overflow-y-auto), where Joyride's default absolute tooltip strategy + * misplaces the floater. Force fixed positioning so the tooltip is + * viewport-anchored like the spotlight. + */ +function mobileStep(step: TourStep): TourStep { + return { + ...step, + floatingOptions: { ...step.floatingOptions, strategy: 'fixed' }, + }; +} + +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: pageTarget('[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: pageTarget('[data-tour="page-materials"]'), + content: 'Access lecture notes, past questions, and study materials uploaded by verified students.', + title: 'Materials Library', + route: '/dashboard/materials', + placement: 'top', + }, + { + target: pageTarget('[data-tour="page-cgpa"]'), + content: 'Add your semester results and instantly calculate your cumulative GPA.', + title: 'CGPA Calculator', + route: '/dashboard/cgpa', + placement: 'bottom', + }, + { + target: pageTarget('[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: pageTarget('[data-tour="page-vendors"]'), + content: 'Connect with verified service providers on campus — from food to fashion.', + title: 'Vendors Marketplace', + route: '/dashboard/vendors', + placement:'top', + }, + { + target: pageTarget('[data-tour="page-announcements"]'), + content: 'Stay updated with department and university announcements.', + title: 'Announcements', + route: '/dashboard/announcements', + placement: 'bottom', + }, + { + target: pageTarget('[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: pageTarget('[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: pageTarget('[data-tour="page-analytics"]'), + content: 'Dive into views, contacts, ratings, and conversion data over time.', + title: 'Analytics', + route: '/dashboard/vendors/analytics', + placement: 'left', + }, + { + target: pageTarget('[data-tour="page-subscription"]'), + content: 'Manage your subscription plan, view billing history, and upgrade or cancel.', + title: 'Subscription', + route: '/dashboard/subscription', + placement: 'bottom', + }, + { + target: pageTarget('[data-tour="page-notifications"]'), + content: 'Get alerts for inquiries and activity related to your business.', + title: 'Notifications', + route: '/dashboard/notifications', + placement: 'bottom', + }, + { + target: pageTarget('[data-tour="page-settings"]'), + content: 'Customize your appearance and manage your account.', + title: 'Settings', + route: '/dashboard/settings', + placement: 'bottom', + }, + ); + + return steps; +} + +/** + * Short mobile tour (5 steps max). The bottom bar replaces the sidebar, so the + * navigation step targets the fixed bottom nav instead. Page steps stay compact + * and keep the tooltip below the highlight so it stays inside the viewport. + */ +export const studentMobileTourSteps: TourStep[] = [ + mobileStep({ + target: visibleTarget('[data-tour="student-welcome"]'), + content: 'This quick tour will show you around your dashboard. Tap Next to begin.', + title: 'Welcome to UniHub', + route: '/dashboard', + placement: 'bottom', + }), + mobileStep({ + target: navTarget, + content: 'Use this bar to jump between your Dashboard, Materials, CGPA, Vendors, and Announcements.', + title: 'Navigate anywhere', + route: '/dashboard', + placement: 'top', + }), + mobileStep({ + target: pageTarget('[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', + }), + mobileStep({ + target: pageTarget('[data-tour="page-cgpa"]'), + content: 'Add your semester results and instantly calculate your cumulative GPA.', + title: 'CGPA Calculator', + route: '/dashboard/cgpa', + placement: 'bottom', + }), + mobileStep({ + target: pageTarget('[data-tour="page-announcements"]'), + content: 'Stay updated with the latest news and announcements from your institution.', + title: 'Announcements', + route: '/dashboard/announcements', + placement: 'bottom', + }), + mobileStep({ + target: pageTarget('[data-tour="page-vendors"]'), + content: 'Connect with verified service providers on campus — from food to fashion.', + title: 'Vendors Marketplace', + route: '/dashboard/vendors', + placement: 'bottom', + }), + mobileStep({ + target: pageTarget('[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', + }) +]; + +export function vendorMobileTourSteps(): TourStep[] { + return [ + mobileStep({ + target: visibleTarget('[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', + }), + mobileStep({ + target: navTarget, + content: 'Use this bar to access analytics, subscription, notifications, and settings.', + title: 'Vendor navigation', + route: '/dashboard', + placement: 'top', + }), + mobileStep({ + target: pageTarget('[data-tour="page-analytics"]'), + content: 'Dive into views, contacts, ratings, and conversion data over time.', + title: 'Analytics', + route: '/dashboard/vendors/analytics', + placement: 'bottom', + }), + mobileStep({ + target: pageTarget('[data-tour="page-notifications"]'), + content: 'Get alerts for inquiries and activity related to your business.', + title: 'Notifications', + route: '/dashboard/notifications', + placement: 'bottom', + }), + mobileStep({ + target: pageTarget('[data-tour="page-settings"]'), + content: 'Customize your appearance and manage your account.', + title: 'Settings', + route: '/dashboard/settings', + placement: 'bottom', + }), + ]; +} 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 f72fbbd..d25f852 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 ( <> -