190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useContext, useEffect, useRef, useCallback } from 'react';
|
|
import { usePathname } from 'next/navigation';
|
|
import { driver, type DriveStep, type Driver } from 'driver.js';
|
|
import 'driver.js/dist/driver.css';
|
|
import '@/styles/driver-onboarding.css';
|
|
import {
|
|
MENTOR_ONBOARDING,
|
|
CLIENT_ONBOARDING,
|
|
PARENT_ONBOARDING,
|
|
getOnboardingKey,
|
|
getOnboardingProgress,
|
|
} from '@/lib/onboarding-steps';
|
|
import { getProfileSettings, updateProfileSettings } from '@/api/profile';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
|
|
type Role = 'mentor' | 'client' | 'parent';
|
|
|
|
interface OnboardingContextType {
|
|
markTourSeen: (pageId: string) => Promise<void>;
|
|
runTourManually: (pageKey: string, options?: { force?: boolean }) => void;
|
|
getProgress: () => { seen: number; total: number };
|
|
refreshProgress: () => Promise<void>;
|
|
}
|
|
|
|
const OnboardingContext = createContext<OnboardingContextType | null>(null);
|
|
|
|
export function useOnboarding() {
|
|
return useContext(OnboardingContext);
|
|
}
|
|
|
|
export function OnboardingProvider({ children }: { children: React.ReactNode }) {
|
|
const pathname = usePathname();
|
|
const { user } = useAuth();
|
|
const toursSeenRef = useRef<Record<string, boolean>>({});
|
|
const driverRef = useRef<Driver | null>(null);
|
|
|
|
const markTourSeen = useCallback(async (pageId: string) => {
|
|
if (!pageId) return;
|
|
toursSeenRef.current[pageId] = true;
|
|
try {
|
|
await updateProfileSettings({
|
|
onboarding_tours_seen: { [pageId]: true },
|
|
});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
const runTour = useCallback(
|
|
(
|
|
config: { pageId: string; steps: { element?: string; popover: { title: string; description: string; side?: string; align?: string } }[] },
|
|
skipSeenCheck?: boolean
|
|
) => {
|
|
if (!skipSeenCheck && toursSeenRef.current[config.pageId]) return;
|
|
if (!config.steps.length) return;
|
|
|
|
// Преобразуем шаги в формат driver.js
|
|
const steps: DriveStep[] = config.steps
|
|
.map((s) => {
|
|
// driver.js: если элемент не найден, step показывается как overlay по центру
|
|
const element = s.element && document.querySelector(s.element) ? s.element : undefined;
|
|
return {
|
|
element: element || undefined,
|
|
popover: {
|
|
title: s.popover.title,
|
|
description: s.popover.description,
|
|
side: (s.popover.side as 'top' | 'right' | 'bottom' | 'left') || 'bottom',
|
|
align: (s.popover.align as 'start' | 'center' | 'end') || 'center',
|
|
},
|
|
};
|
|
})
|
|
.filter((s) => s.element || s.popover);
|
|
|
|
if (steps.length === 0) return;
|
|
|
|
if (driverRef.current) {
|
|
driverRef.current.destroy();
|
|
driverRef.current = null;
|
|
}
|
|
|
|
const driverObj = driver({
|
|
showProgress: true,
|
|
steps,
|
|
nextBtnText: 'Далее',
|
|
prevBtnText: 'Назад',
|
|
doneBtnText: 'Понятно',
|
|
progressText: '{{current}} из {{total}}',
|
|
popoverClass: 'driver-onboarding-friendly',
|
|
onDestroyStarted: () => {
|
|
markTourSeen(config.pageId);
|
|
driverObj.destroy();
|
|
driverRef.current = null;
|
|
},
|
|
});
|
|
driverRef.current = driverObj;
|
|
driverObj.drive();
|
|
},
|
|
[markTourSeen]
|
|
);
|
|
|
|
const runTourManually = useCallback(
|
|
(pageKey: string, options?: { force?: boolean }) => {
|
|
const role = user?.role as Role;
|
|
if (!role || !['mentor', 'client', 'parent'].includes(role)) return;
|
|
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
|
|
const config = configs[pageKey];
|
|
if (config) runTour(config, options?.force);
|
|
},
|
|
[user?.role, runTour]
|
|
);
|
|
|
|
const getProgress = useCallback(() => {
|
|
const role = user?.role as Role;
|
|
if (!role || !['mentor', 'client', 'parent'].includes(role)) return { seen: 0, total: 0 };
|
|
return getOnboardingProgress(toursSeenRef.current, role);
|
|
}, [user?.role]);
|
|
|
|
const refreshProgress = useCallback(async () => {
|
|
try {
|
|
const settings = await getProfileSettings();
|
|
const seen = settings?.onboarding_tours_seen ?? {};
|
|
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
const runTourRef = useRef(runTour);
|
|
runTourRef.current = runTour;
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
getProfileSettings()
|
|
.then((s) => {
|
|
const seen = s?.onboarding_tours_seen ?? {};
|
|
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
|
|
})
|
|
.catch(() => {});
|
|
}, [user?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!user || !pathname) return;
|
|
const role = user.role as Role;
|
|
if (!['mentor', 'client', 'parent'].includes(role)) return;
|
|
|
|
if (pathname.startsWith('/login') || pathname.startsWith('/register') || pathname.startsWith('/livekit')) return;
|
|
|
|
const key = getOnboardingKey(pathname, role);
|
|
if (!key) return;
|
|
|
|
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
|
|
const config = configs[key];
|
|
if (!config) return;
|
|
|
|
let cancelled = false;
|
|
const loadAndRun = async () => {
|
|
try {
|
|
const settings = await getProfileSettings();
|
|
if (cancelled) return;
|
|
const seen = settings?.onboarding_tours_seen ?? {};
|
|
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
|
|
if (seen[config.pageId]) return;
|
|
|
|
setTimeout(() => {
|
|
if (!cancelled) runTourRef.current(config);
|
|
}, 600);
|
|
} catch {
|
|
// при ошибке не показываем тур
|
|
}
|
|
};
|
|
loadAndRun();
|
|
return () => { cancelled = true; };
|
|
}, [pathname, user]);
|
|
|
|
const value: OnboardingContextType = {
|
|
markTourSeen,
|
|
runTourManually,
|
|
getProgress,
|
|
refreshProgress,
|
|
};
|
|
|
|
return (
|
|
<OnboardingContext.Provider value={value}>
|
|
{children}
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|