Files
clinicpro/assets/admin/hooks/useTour.ts
T
hamed 20c8eaaad9 feat: add TourProgressController and related entities for user tour progress tracking
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
2026-08-10 09:34:14 +03:30

77 lines
3.1 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import { getTour } from '../lib/tour/registry';
import { anchorSelector, resolveSteps } from '../lib/tour/resolveSteps';
import { formatNumber } from '../lib/utils';
import { useTourProgress } from './useTourProgress';
interface Options {
/** وقتی true شد یعنی دادهٔ صفحه آمده و المان‌های هدف رندر شده‌اند */
ready?: boolean;
}
/**
* راهنمای قدم‌به‌قدم یک صفحه. بار اول خودکار اجرا می‌شود و بعد از آن فقط با
* صدا زدن start — یعنی دکمهٔ «؟» صفحه.
*/
export function useTour(tourId?: string, { ready = false }: Options = {}) {
const tour = getTour(tourId);
const { isReady, isSeen, markSeen } = useTourProgress();
const autoStarted = useRef(false);
const start = useCallback(() => {
if (!tour) return;
const steps = resolveSteps(tour.steps);
if (steps.length === 0) return;
driver({
showProgress: true,
allowClose: true,
overlayOpacity: 0.55,
popoverClass: 'cp-tour',
nextBtnText: 'بعدی',
prevBtnText: 'قبلی',
doneBtnText: 'باشه، فهمیدم',
steps: steps.map((s, i) => ({
element: anchorSelector(s.anchor),
popover: {
title: s.title,
description: s.body,
side: s.side ?? 'bottom',
align: 'start',
// قالبِ سراسری driver فقط {{current}} می‌دهد و آن ارقام لاتین است؛
// شمارنده باید مثل بقیهٔ پنل فارسی باشد.
progressText: `${formatNumber(i + 1)} از ${formatNumber(steps.length)}`,
},
})),
// بستن وسط تور هم «دیده شده» است؛ تکرارش برای کسی که ردش کرده آزار است.
onDestroyed: () => markSeen(tour.id, tour.version),
}).drive();
}, [tour, markSeen]);
// `start` با هر رندر بازساخته می‌شود؛ اگر وابستگیِ effect باشد، cleanup تایمرِ
// سیصد میلی‌ثانیه‌ای را قبل از شلیک پاک می‌کند و تور هرگز اجرا نمی‌شود.
const startRef = useRef(start);
startRef.current = start;
// مقدار boolean وابستگیِ پایداری است، برخلاف خودِ تابع isSeen.
const alreadySeen = tour ? isSeen(tour.id, tour.version) : true;
useEffect(() => {
if (!tour || !ready || autoStarted.current) return;
// تا پاسخ سرور نیامده هیچ چیز اجرا نمی‌شود؛ وگرنه در خطای شبکه کاربر قدیمی
// هر بار رفرش یک تور می‌بیند.
if (!isReady || alreadySeen) return;
autoStarted.current = true;
// یک لحظه صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
const timer = window.setTimeout(() => startRef.current(), 300);
return () => window.clearTimeout(timer);
}, [tour, ready, isReady, alreadySeen]);
return { available: tour !== null, start };
}