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.
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
# راهنمای قدمبهقدم صفحات پنل ادمین — فاز ۱ (زیرساخت + صفحهٔ نوبتها)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` — پنل ادمین React داخل `assets/admin/`.
|
||||
|
||||
## زمینه
|
||||
|
||||
پنل ادمین حدود ۵۰ صفحه دارد و هیچ راهنمای درونبرنامهای ندارد.
|
||||
کاربر تازهوارد نمیداند هر بخش صفحه چه کار میکند.
|
||||
تصمیم گرفته شد راهنما به شکل **تور قدمبهقدم** باشد.
|
||||
یعنی المانها یکییکی highlight میشوند و کنارشان یک popover فارسی توضیح میدهد.
|
||||
|
||||
این فایل فقط **فاز ۱** است.
|
||||
فاز ۱ = زیرساخت تور + پیادهسازی روی یک صفحهٔ نمونه.
|
||||
صفحهٔ نمونه: `AppointmentsPage`.
|
||||
دلیل انتخابش: شلوغترین صفحهٔ پنل است و المانهای نقشمحور دارد.
|
||||
بعد از تأیید ظاهر و رفتار تور، فاز ۲ نوشته میشود که همین الگو را روی بقیهٔ صفحات تکرار میکند.
|
||||
|
||||
**در این فاز هیچ صفحهٔ دیگری را دست نزن.**
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
هدف:
|
||||
|
||||
- یک زیرساخت واحد برای تعریف تور هر صفحه.
|
||||
- تعریف تور بهصورت داده باشد، نه کد پراکنده در صفحات.
|
||||
- المانهای هدف با اتریبیوت `data-tour` مشخص شوند.
|
||||
- استپی که المانش در DOM نیست بیسروصدا حذف شود، نه اینکه تور بشکند.
|
||||
- بار اول ورود کاربر به صفحه، تور خودکار اجرا شود.
|
||||
- بعد از دیدن، دیگر خودکار اجرا نشود؛ ولی با یک دکمهٔ `؟` قابل اجرای دوباره باشد.
|
||||
- محتوای تور نسخهدار باشد؛ با بالا بردن نسخه، تور دوباره یکبار خودکار اجرا شود.
|
||||
|
||||
## تصمیم فنی — چرا driver.js
|
||||
|
||||
سه گزینه بررسی شد:
|
||||
|
||||
- `react-joyride` — سنگینتر و روی React 19 مشکوک است. `react-floater` هنوز peer آن React 18 است.
|
||||
- کامپوننت دستساز — منطق overlay و اسکرول و resize و sticky header باید از صفر نوشته شود. کد زیاد و باگخیز.
|
||||
- `driver.js` نسخهٔ ۱ — بدون dependency، حدود ۵ کیلوبایت gzip، مستقل از فریمورک، خودش overlay و اسکرول و reposition را دارد.
|
||||
|
||||
انتخاب: **`driver.js`**.
|
||||
استایلش با توکنهای `styles.css` override میشود تا با تم روشن و تیره یکی شود.
|
||||
راستچین بودن مشکلی ندارد چون ریشهٔ اپ `dir="rtl"` است.
|
||||
|
||||
نصب:
|
||||
|
||||
```bash
|
||||
ddev exec npm install driver.js@^1.3.6
|
||||
```
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: ورود با `09390039833` به `/admin/appointments` برای اولین بار → تور خودکار اجرا میشود. متنها فارسیاند. شمارنده «۱ از N» است. دکمهها «بعدی / قبلی / باشه، فهمیدم». بعد از پایان، در `localStorage['clinicpro-tours']` کلید `seen.appointments` برابر نسخهٔ تور میشود. رفرش صفحه → تور دیگر خودکار اجرا نمیشود. کلیک روی دکمهٔ `؟` کنار عنوان → تور دوباره از استپ اول اجرا میشود.
|
||||
- ❌ خطا: `useTour('does-not-exist')` → هیچ دکمهای رندر نمیشود، هیچ خطایی throw نمیشود و تور اجرا نمیشود. همچنین اگر هیچکدام از المانهای تور در DOM نباشد، `start()` هیچ کاری نمیکند و crash نمیدهد.
|
||||
- ⚠️ مرزی: ورود با نقش `doctor` که تب پزشکان ندارد، و منشیِ بدون مجوز `appointments.create` که دکمهٔ «افزودن نوبت» ندارد → استپهای مربوط به آن المانها حذف میشوند، تور با استپهای کمتر اجرا میشود و شمارنده درست است، مثلاً «۱ از ۵» نه «۱ از ۷». همچنین بالا بردن `version` تور → یکبار دیگر خودکار اجرا میشود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/lib/tour/types.ts` | جدید — تعریف تایپ استپ و تور |
|
||||
| `assets/admin/lib/tour/resolveSteps.ts` | جدید — تابع خالص فیلتر استپها بر اساس وجود المان |
|
||||
| `assets/admin/lib/tour/registry.ts` | جدید — رجیستری تورها بر اساس id |
|
||||
| `assets/admin/lib/tour/tours/appointments.ts` | جدید — تعریف تور صفحهٔ نوبتها |
|
||||
| `assets/admin/stores/tourStore.ts` | جدید — zustand persist برای تورهای دیدهشده |
|
||||
| `assets/admin/hooks/useTour.ts` | جدید — اجرای تور و اجرای خودکار بار اول |
|
||||
| `assets/admin/components/ui/TourButton.tsx` | جدید — دکمهٔ `؟` راهنمای صفحه |
|
||||
| `assets/admin/components/ui/PageHeader.tsx` | تغییر — پراپ اختیاری `tourId` |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx` | تغییر — افزودن `data-tour` و دکمهٔ راهنما |
|
||||
| `assets/admin/styles.css` | تغییر — override استایل popover با توکنها |
|
||||
| `package.json` | تغییر — افزودن `driver.js` |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`PageHeader` هیچ جای راهنما ندارد. کد فعلی:
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
title: string;
|
||||
breadcrumbs?: Crumb[];
|
||||
action?: React.ReactNode;
|
||||
description?: string;
|
||||
backTo?: string;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
|
||||
...
|
||||
<h1 className="section-title">{title}</h1>
|
||||
```
|
||||
|
||||
`AppointmentsPage` از `PageHeader` استفاده نمیکند و عنوان دستساز دارد. کد فعلی از خط ۴۲۰:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
||||
{/* عنوان */}
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
||||
|
||||
{/* نوار آمار */}
|
||||
<TurnsStatInfo stats={stats} />
|
||||
|
||||
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
||||
}}>
|
||||
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
|
||||
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
||||
|
||||
<ServiceFilterSelect ... />
|
||||
|
||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
||||
<button aria-label="فیلترها" className="btn sm" onClick={() => setFiltersOpen(true)} ... >
|
||||
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
|
||||
{!isRepresentation && canCreateAppt && (
|
||||
<button className="btn primary sm" onClick={...}>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
افزودن نوبت
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
الگوی persist موجود در `stores/uiStore.ts` مرجع است:
|
||||
|
||||
```ts
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set, get) => ({ ... }),
|
||||
{ name: 'clinicpro-ui', onRehydrateStorage: ... },
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. نصب driver.js
|
||||
|
||||
```bash
|
||||
ddev exec npm install driver.js@^1.3.6
|
||||
```
|
||||
|
||||
**نحوه تست:** `driver.js` در `dependencies` فایل `package.json` باشد و `ddev exec yarn dev` بدون خطای resolve تمام شود.
|
||||
|
||||
---
|
||||
|
||||
### ۲. تایپها و تابع خالص resolve
|
||||
|
||||
`assets/admin/lib/tour/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface TourStep {
|
||||
/** مقدار اتریبیوت data-tour روی المان هدف */
|
||||
anchor: string;
|
||||
title: string;
|
||||
body: string;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface TourDefinition {
|
||||
/** شناسهٔ یکتا؛ معمولاً همنام مسیر صفحه */
|
||||
id: string;
|
||||
/** با هر تغییر محتوای تور یکی زیاد شود تا تور یکبار دیگر خودکار اجرا شود */
|
||||
version: number;
|
||||
steps: TourStep[];
|
||||
}
|
||||
```
|
||||
|
||||
`assets/admin/lib/tour/resolveSteps.ts`:
|
||||
|
||||
```ts
|
||||
import type { TourStep } from './types';
|
||||
|
||||
export function anchorSelector(anchor: string): string {
|
||||
return `[data-tour="${anchor}"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط استپهایی میمانند که المانشان همین حالا در DOM هست.
|
||||
* دلیلش نقشمحور بودن صفحات است: دکمهٔ «افزودن نوبت» برای منشیِ بدون مجوز
|
||||
* اصلاً رندر نمیشود و تور نباید روی یک المان غایب گیر کند.
|
||||
*/
|
||||
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
|
||||
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
|
||||
}
|
||||
```
|
||||
|
||||
**نحوه تست:** تست واحد `lib/tour/resolveSteps.test.ts` با vitest و jsdom:
|
||||
استپ موجود میماند، استپ غایب حذف میشود، ترتیب استپهای باقیمانده حفظ میشود، آرایهٔ خالی → خروجی خالی.
|
||||
|
||||
---
|
||||
|
||||
### ۳. رجیستری تورها
|
||||
|
||||
`assets/admin/lib/tour/registry.ts`:
|
||||
|
||||
```ts
|
||||
import type { TourDefinition } from './types';
|
||||
import { appointmentsTour } from './tours/appointments';
|
||||
|
||||
/** هر صفحه یک فایل جدا در tours/ دارد؛ اینجا فقط ثبت میشود. */
|
||||
export const TOURS: Record<string, TourDefinition> = {
|
||||
[appointmentsTour.id]: appointmentsTour,
|
||||
};
|
||||
|
||||
export function getTour(id?: string): TourDefinition | null {
|
||||
return id ? TOURS[id] ?? null : null;
|
||||
}
|
||||
```
|
||||
|
||||
هر تور در فایل خودش، تا فاز ۲ فقط «فایل جدید + یک خط ثبت» باشد.
|
||||
|
||||
`assets/admin/lib/tour/tours/appointments.ts`:
|
||||
|
||||
```ts
|
||||
import type { TourDefinition } from '../types';
|
||||
|
||||
export const appointmentsTour: TourDefinition = {
|
||||
id: 'appointments',
|
||||
version: 1,
|
||||
steps: [
|
||||
{ anchor: 'appointments-stats', title: 'آمار امروز', body: 'تعداد کل نوبتها، انجامشدهها، در انتظار و لغوشدههای همین روز.', side: 'bottom' },
|
||||
{ anchor: 'appointments-date', title: 'انتخاب روز', body: 'با فلشها یک روز جلو و عقب بروید یا از تقویم یک تاریخ را انتخاب کنید.', side: 'bottom' },
|
||||
{ anchor: 'appointments-service', title: 'فیلتر خدمت', body: 'فقط نوبتهای یک خدمت مشخص را ببینید.', side: 'bottom' },
|
||||
{ anchor: 'appointments-view', title: 'نمای تایملاین یا جدول', body: 'تایملاین ساعتهای روز را نشان میدهد و جدول فهرست ساده است.', side: 'bottom' },
|
||||
{ anchor: 'appointments-filters', title: 'فیلترهای بیشتر', body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار.', side: 'bottom' },
|
||||
{ anchor: 'appointments-new', title: 'ثبت نوبت جدید', body: 'برای همان روز و همان پزشکِ انتخابشده نوبت ثبت میکند.', side: 'bottom' },
|
||||
{ anchor: 'appointments-doctors', title: 'تب پزشکان', body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.', side: 'bottom' },
|
||||
{ anchor: 'appointments-list', title: 'فهرست نوبتها', body: 'با کلیک روی هر نوبت وارد جزئیات و عملیات آن میشوید.', side: 'top' },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**نحوه تست:** تست واحد بررسی کند `id` تور خالی نیست، `version` عدد مثبت است و `anchor`ها تکراری نیستند.
|
||||
|
||||
---
|
||||
|
||||
### ۴. استور تورهای دیدهشده
|
||||
|
||||
`assets/admin/stores/tourStore.ts` — دقیقاً الگوی `uiStore`:
|
||||
|
||||
```ts
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface TourState {
|
||||
/** tourId → نسخهای که کاربر دیده است */
|
||||
seen: Record<string, number>;
|
||||
markSeen: (id: string, version: number) => void;
|
||||
isSeen: (id: string, version: number) => boolean;
|
||||
/** بدون آرگومان یعنی پاک کردن همهٔ تورها */
|
||||
reset: (id?: string) => void;
|
||||
}
|
||||
|
||||
export const useTourStore = create<TourState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
seen: {},
|
||||
markSeen: (id, version) => set((s) => ({ seen: { ...s.seen, [id]: version } })),
|
||||
isSeen: (id, version) => (get().seen[id] ?? 0) >= version,
|
||||
reset: (id) => set((s) => {
|
||||
if (!id) return { seen: {} };
|
||||
const next = { ...s.seen };
|
||||
delete next[id];
|
||||
return { seen: next };
|
||||
}),
|
||||
}),
|
||||
{ name: 'clinicpro-tours' },
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**نحوه تست:** `stores/tourStore.test.ts` — ابتدا `isSeen('x', 1) === false`؛ بعد از `markSeen('x', 1)` برابر `true`؛ با `isSeen('x', 2)` دوباره `false`؛ `reset('x')` پاکش میکند.
|
||||
|
||||
---
|
||||
|
||||
### ۵. هوک useTour
|
||||
|
||||
`assets/admin/hooks/useTour.ts`:
|
||||
|
||||
```ts
|
||||
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 { useTourStore } from '../stores/tourStore';
|
||||
|
||||
interface Options {
|
||||
/** وقتی true شد یعنی دادهٔ صفحه آمده و المانها رندر شدهاند */
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
export function useTour(tourId?: string, { ready = true }: Options = {}) {
|
||||
const tour = getTour(tourId);
|
||||
const markSeen = useTourStore((s) => s.markSeen);
|
||||
const isSeen = useTourStore((s) => s.isSeen);
|
||||
const autoStarted = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!tour) return;
|
||||
const steps = resolveSteps(tour.steps);
|
||||
if (steps.length === 0) return;
|
||||
|
||||
const d = driver({
|
||||
showProgress: true,
|
||||
allowClose: true,
|
||||
overlayOpacity: 0.55,
|
||||
popoverClass: 'cp-tour',
|
||||
nextBtnText: 'بعدی',
|
||||
prevBtnText: 'قبلی',
|
||||
doneBtnText: 'باشه، فهمیدم',
|
||||
progressText: '{{current}} از {{total}}',
|
||||
steps: steps.map((s) => ({
|
||||
element: anchorSelector(s.anchor),
|
||||
popover: { title: s.title, description: s.body, side: s.side ?? 'bottom', align: 'start' },
|
||||
})),
|
||||
onDestroyed: () => markSeen(tour.id, tour.version),
|
||||
});
|
||||
d.drive();
|
||||
}, [tour, markSeen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tour || !ready || autoStarted.current) return;
|
||||
if (isSeen(tour.id, tour.version)) return;
|
||||
autoStarted.current = true;
|
||||
// یک فریم صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
|
||||
const t = window.setTimeout(start, 300);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [tour, ready, isSeen, start]);
|
||||
|
||||
return { available: tour !== null, start };
|
||||
}
|
||||
```
|
||||
|
||||
نکته: `autoStarted` جلوی اجرای دوبارهٔ تور در رندرهای بعدی همان صفحه را میگیرد.
|
||||
|
||||
**نحوه تست:** تست کامپوننتی با mock کردن ماژول `driver.js`:
|
||||
با تور دیدهنشده و `ready: true`، بعد از پیشرفتن تایمر، `drive()` صدا زده میشود؛
|
||||
با تور دیدهشده صدا زده نمیشود؛ با `tourId` ناشناس هم صدا زده نمیشود.
|
||||
|
||||
---
|
||||
|
||||
### ۶. دکمهٔ راهنما
|
||||
|
||||
`assets/admin/components/ui/TourButton.tsx`:
|
||||
|
||||
```tsx
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useTour } from '../../hooks/useTour';
|
||||
|
||||
/** دکمهٔ «؟» صفحه. اگر برای این صفحه توری ثبت نشده باشد، چیزی رندر نمیکند. */
|
||||
export default function TourButton({ tourId }: { tourId?: string }) {
|
||||
const { available, start } = useTour(tourId, { ready: false });
|
||||
if (!available) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="راهنمای این صفحه"
|
||||
title="راهنمای این صفحه"
|
||||
onClick={start}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 'var(--r-pill)',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-3)',
|
||||
}}
|
||||
>
|
||||
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
مهم: در `TourButton` مقدار `ready: false` داده میشود تا **دکمه** مسئول اجرای خودکار نباشد.
|
||||
اجرای خودکار وظیفهٔ خودِ صفحه است که میداند دادهاش کی آماده است.
|
||||
|
||||
`PageHeader` یک پراپ اختیاری میگیرد و دکمه را کنار عنوان میگذارد:
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
title: string;
|
||||
breadcrumbs?: Crumb[];
|
||||
action?: React.ReactNode;
|
||||
description?: string;
|
||||
backTo?: string;
|
||||
/** شناسهٔ تور راهنمای این صفحه؛ اگر ثبت نشده باشد دکمهای نمیآید */
|
||||
tourId?: string;
|
||||
}
|
||||
|
||||
// ...
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<h1 className="section-title">{title}</h1>
|
||||
<TourButton tourId={tourId} />
|
||||
</div>
|
||||
```
|
||||
|
||||
`action` دستنخورده میماند.
|
||||
|
||||
**نحوه تست:** `components/ui/TourButton.test.tsx` — با `tourId="appointments"` دکمه با `aria-label` «راهنمای این صفحه» رندر میشود؛ با `tourId="nope"` و بدون `tourId` هیچ دکمهای رندر نمیشود.
|
||||
|
||||
---
|
||||
|
||||
### ۷. استایل popover با توکنهای پروژه
|
||||
|
||||
در `assets/admin/styles.css` بعد از توکنها:
|
||||
|
||||
```css
|
||||
/* تور راهنما — ظاهر driver.js با توکنهای پنل یکی میشود (روشن و تیره) */
|
||||
.driver-popover.cp-tour {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-family: inherit;
|
||||
max-width: 320px;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-title { color: var(--text); font-size: 14px; font-weight: 700; }
|
||||
.driver-popover.cp-tour .driver-popover-description { color: var(--text-2); font-size: 13px; line-height: 1.9; }
|
||||
.driver-popover.cp-tour .driver-popover-progress-text { color: var(--text-3); font-size: 12px; }
|
||||
.driver-popover.cp-tour .driver-popover-navigation-btns button {
|
||||
background: var(--surface-2); color: var(--text-2);
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
font-family: inherit; font-size: 12px; text-shadow: none;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-navigation-btns button:last-child {
|
||||
background: var(--primary); color: var(--on-primary); border-color: var(--primary);
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-top { border-top-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-left { border-left-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-right { border-right-color: var(--surface); }
|
||||
```
|
||||
|
||||
**نحوه تست:** چشمی. یکبار در تم روشن و یکبار در تم تیره تور را اجرا کن. متن و دکمهها باید خوانا باشند و رنگ دکمهٔ آخر همان رنگ برند باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۸. اتصال به صفحهٔ نوبتها
|
||||
|
||||
در `pages/AppointmentsPage.tsx`:
|
||||
|
||||
اجرای خودکار وقتی دادهٔ صفحه آمد:
|
||||
|
||||
```tsx
|
||||
const { start: startTour } = useTour('appointments', { ready: !isLoading });
|
||||
```
|
||||
|
||||
`isLoading` را از همان `useQuery` نوبتهای صفحه بگیر؛ اسم متغیر واقعی را از کد بردار، نگذار حدس زده شود.
|
||||
|
||||
عنوان صفحه دکمهٔ راهنما بگیرد:
|
||||
|
||||
```tsx
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
|
||||
<TourButton tourId="appointments" />
|
||||
</div>
|
||||
```
|
||||
|
||||
اتریبیوتها روی همان hostهای موجود، بدون تغییر در کامپوننتهای فرزند:
|
||||
|
||||
```tsx
|
||||
<div data-tour="appointments-stats"><TurnsStatInfo stats={stats} /></div>
|
||||
|
||||
<div data-tour="appointments-date"><DateNavigator date={selectedDate} onChange={setSelectedDate} /></div>
|
||||
|
||||
<div data-tour="appointments-service"><ServiceFilterSelect ... /></div>
|
||||
|
||||
<div data-tour="appointments-view"><TurnsViewToggle viewMode={viewMode} onChange={setViewMode} /></div>
|
||||
|
||||
<button data-tour="appointments-filters" aria-label="فیلترها" ... />
|
||||
|
||||
<button data-tour="appointments-new" className="btn primary sm" ... />
|
||||
```
|
||||
|
||||
روی کارت اصلی `data-tour="appointments-list"` و روی بلوک `showDoctorTabs` مقدار `data-tour="appointments-doctors"`.
|
||||
|
||||
قید مهم: `TurnsStatInfo`، `TurnsViewToggle`، `DoctorTabs` و `ServiceFilterSelect` **تغییر نکنند**.
|
||||
فقط دور آنها یک `div` با `data-tour` گذاشته شود.
|
||||
دلیلش این است که این کامپوننتها جای دیگری هم استفاده میشوند و تور نباید داخلشان نشت کند.
|
||||
مواظب باش `div` اضافه چیدمان `flex` نوار ابزار را نشکند؛ اگر شکست، `display: 'contents'` روی wrapper بگذار
|
||||
یا `data-tour` را مستقیم روی ریشهٔ همان کامپوننت از طریق پراپ عبور بده — گزینهٔ دوم فقط اگر گزینهٔ اول جواب نداد.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||
ddev exec yarn test
|
||||
ddev exec yarn dev
|
||||
```
|
||||
|
||||
بعد ورود دستی با `09390039833 / 09390039833` و باز کردن `/admin/appointments`.
|
||||
سناریوهای بخش «معیار پذیرش» یکییکی چک شوند.
|
||||
برای تست دوبارهٔ اجرای خودکار، در کنسول مرورگر:
|
||||
|
||||
```js
|
||||
localStorage.removeItem('clinicpro-tours'); location.reload();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- محتوای تور فقط **داده** است، در `lib/tour/tours/*.ts`. هیچ متن راهنمایی داخل JSX صفحات نوشته نشود. هدف این است که فاز ۲ برای هر صفحه فقط «یک فایل تور + چند `data-tour` + یک `tourId`» باشد.
|
||||
- `resolveSteps` عمداً یک تابع خالص جداست تا بدون رندر کردن صفحه تست شود.
|
||||
- استپ غایب = حذف بیصدا. هیچ استپی نباید «اجباری» باشد، چون همهٔ صفحات پنل نقشمحورند.
|
||||
- `version` تور دلیل وجودی دارد: متن راهنما که عوض شد، کاربر قدیمی هم باید یکبار ببیندش. بدون version هیچوقت دوباره نمایش داده نمیشود.
|
||||
- کلید `localStorage` جدید `clinicpro-tours` است. با `clinicpro-auth` و `clinicpro-ui` قاطی نشود.
|
||||
- اجرای خودکار حتماً به `ready` گره بخورد. اگر قبل از آمدن داده اجرا شود، المانها هنوز نیستند و تور خالی میماند.
|
||||
- خروجیِ این فاز باید همان تصمیمِ نهایی «نوع helper» باشد. اگر ظاهر یا لحن متنها مطلوب نبود، فقط `tours/appointments.ts` و بلوک CSS عوض میشوند، نه معماری.
|
||||
- طبق قاعدهٔ پروژه هیچ تسکی بدون تست موفق و خطا و مرزی تمام نیست. تستهای بند ۲ و ۴ و ۵ و ۶ اجباریاند.
|
||||
- این تغییر backend ندارد، پس `docs/api/` دست نمیخورد.
|
||||
- فاز ۲ بعد از تأیید نوشته میشود: تکرار همین الگو روی بقیهٔ صفحات، با تکیه بر پراپ `tourId` در `PageHeader` که ۸۲ نقطهٔ استفاده دارد.
|
||||
Reference in New Issue
Block a user