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` که ۸۲ نقطهٔ استفاده دارد.
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||||
import BackButton from './BackButton';
|
import BackButton from './BackButton';
|
||||||
|
import TourButton from './TourButton';
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,9 +19,11 @@ interface Props {
|
|||||||
* مقدار، مقصدِ fallback است وقتی تاریخچهای برای برگشتن نیست.
|
* مقدار، مقصدِ fallback است وقتی تاریخچهای برای برگشتن نیست.
|
||||||
*/
|
*/
|
||||||
backTo?: string;
|
backTo?: string;
|
||||||
|
/** شناسهٔ تور راهنمای این صفحه؛ اگر در registry ثبت نشده باشد دکمهای نمیآید. */
|
||||||
|
tourId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
|
export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId }: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
@@ -48,7 +51,10 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<h1 className="section-title">{title}</h1>
|
<h1 className="section-title">{title}</h1>
|
||||||
|
<TourButton tourId={tourId} />
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
|
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '@/test/utils';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import TourButton from './TourButton';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
drive.mockReset();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TourButton', () => {
|
||||||
|
it('برای تور ثبتشده دکمهٔ راهنما میآورد', () => {
|
||||||
|
renderWithProviders(<TourButton tourId="appointments" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای شناسهٔ ناشناس یا بدون شناسه چیزی رندر نمیکند', () => {
|
||||||
|
const { container } = renderWithProviders(<TourButton tourId="does-not-exist" />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
const { container: bare } = renderWithProviders(<TourButton />);
|
||||||
|
expect(bare).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیک، تور را روی المانهای موجود اجرا میکند', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithProviders(
|
||||||
|
<>
|
||||||
|
<TourButton tourId="appointments" />
|
||||||
|
{appointmentsTour.steps.map((s) => (
|
||||||
|
<div key={s.anchor} data-tour={s.anchor} />
|
||||||
|
))}
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'راهنمای این صفحه' }));
|
||||||
|
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader', () => {
|
||||||
|
it('با tourId دکمهٔ راهنما را کنار عنوان میگذارد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" tourId="appointments" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'نوبتها' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بدون tourId هیچ دکمهٔ راهنمایی ندارد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'راهنمای این صفحه' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('صفحهٔ بدون تور هیچ درخواستی برای وضعیت تورها نمیفرستد', () => {
|
||||||
|
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||||
|
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useTour } from '../../hooks/useTour';
|
||||||
|
import { getTour } from '../../lib/tour/registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دکمهٔ «راهنمای این صفحه».
|
||||||
|
*
|
||||||
|
* وجود تور قبل از هر هوکی بررسی میشود تا صفحهای که راهنما ندارد — یعنی بیشتر
|
||||||
|
* صفحات پنل — هیچ درخواستی برای وضعیت تورها نفرستد.
|
||||||
|
*/
|
||||||
|
export default function TourButton({ tourId }: { tourId?: string }) {
|
||||||
|
const tour = getTour(tourId);
|
||||||
|
|
||||||
|
if (!tour) return null;
|
||||||
|
|
||||||
|
return <TourLauncher tourId={tour.id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اجرای خودکار وظیفهٔ خود صفحه است؛ این دکمه فقط دستی اجرا میکند. */
|
||||||
|
function TourLauncher({ tourId }: { tourId: string }) {
|
||||||
|
const { start } = useTour(tourId);
|
||||||
|
|
||||||
|
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)', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-3)')}
|
||||||
|
>
|
||||||
|
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { renderHookWithClient } from '@/test/utils';
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
const destroyHandlers: Array<() => void> = [];
|
||||||
|
|
||||||
|
vi.mock('driver.js', () => ({
|
||||||
|
driver: vi.fn((config: { onDestroyed?: () => void }) => {
|
||||||
|
if (config.onDestroyed) destroyHandlers.push(config.onDestroyed);
|
||||||
|
return { drive };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useTour } from '@/hooks/useTour';
|
||||||
|
import { useTourProgress } from '@/hooks/useTourProgress';
|
||||||
|
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const post = api.post as ReturnType<typeof vi.fn>;
|
||||||
|
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function mountAppointmentsAnchors() {
|
||||||
|
document.body.innerHTML = appointmentsTour.steps
|
||||||
|
.map((s) => `<div data-tour="${s.anchor}"></div>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
destroyHandlers.length = 0;
|
||||||
|
get.mockReset();
|
||||||
|
post.mockReset();
|
||||||
|
drive.mockReset();
|
||||||
|
driverMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTourProgress', () => {
|
||||||
|
it('نقشهٔ دیدهشدهها را از سرور میخواند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: { appointments: 2 } } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTourProgress());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||||
|
expect(result.current.isSeen('appointments', 2)).toBe(true);
|
||||||
|
expect(result.current.isSeen('appointments', 3)).toBe(false);
|
||||||
|
expect(result.current.isSeen('patients', 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ثبت دیدهشدن، کش را بدون درخواست دوباره بهروز میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
post.mockResolvedValue({ data: { tourId: 'appointments', version: 1 } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTourProgress());
|
||||||
|
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||||
|
|
||||||
|
result.current.markSeen('appointments', 1);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSeen('appointments', 1)).toBe(true));
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: 1 });
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTour', () => {
|
||||||
|
it('تور دیدهنشده را بعد از آماده شدن صفحه خودکار اجرا میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(driverMock).toHaveBeenCalled());
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(driverMock.mock.calls[0][0].steps).toHaveLength(appointmentsTour.steps.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('شمارندهٔ استپها با ارقام فارسی است', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const steps = driverMock.mock.calls[0][0].steps;
|
||||||
|
expect(steps[0].popover.progressText).toBe('۱ از ۸');
|
||||||
|
expect(steps[7].popover.progressText).toBe('۸ از ۸');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('رندرهای پیاپی قبل از شلیک تایمر، اجرای خودکار را لغو نمیکنند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
const { rerender } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
// هر رندر تابع start تازهای میسازد؛ اگر وابستگیِ effect باشد، cleanup
|
||||||
|
// تایمر را پاک میکند و تور هرگز اجرا نمیشود.
|
||||||
|
for (let i = 0; i < 5; i++) rerender();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('استپِ بدون المان را حذف میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
document.body.innerHTML = '<div data-tour="appointments-stats"></div>';
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(driverMock.mock.calls[0][0].steps).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تور دیدهشده خودکار اجرا نمیشود', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: { appointments: appointmentsTour.version } } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// ولی دکمهٔ راهنما همچنان کار میکند
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تا وقتی وضعیت از سرور نیامده اجرا نمیشود', async () => {
|
||||||
|
get.mockRejectedValue(new Error('network down'));
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('صفحهای که هنوز آماده نیست تور نمیگیرد', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: false }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('شناسهٔ ناشناس نه دکمه دارد نه اجرا', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('nope', { ready: true }));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(result.current.available).toBe(false);
|
||||||
|
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی هیچ المانی روی صفحه نیست، اجرا نمیشود', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
|
||||||
|
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await waitFor(() => expect(result.current.available).toBe(true));
|
||||||
|
|
||||||
|
result.current.start();
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بستن تور، دیدهشدن را ثبت میکند', async () => {
|
||||||
|
get.mockResolvedValue({ data: { seen: {} } });
|
||||||
|
post.mockResolvedValue({ data: { tourId: 'appointments', version: appointmentsTour.version } });
|
||||||
|
mountAppointmentsAnchors();
|
||||||
|
|
||||||
|
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
await waitFor(() => expect(destroyHandlers).toHaveLength(1));
|
||||||
|
|
||||||
|
destroyHandlers[0]();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', {
|
||||||
|
version: appointmentsTour.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import type { ApiResponse } from '../lib/api';
|
||||||
|
|
||||||
|
/** tour id => بالاترین نسخهای که کاربر دیده است */
|
||||||
|
type SeenMap = Record<string, number>;
|
||||||
|
|
||||||
|
export const TOURS_QUERY_KEY = ['my-tours'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* وضعیت «دیدهشده» روی حساب کاربر ذخیره میشود نه روی مرورگر، تا با عوض کردن
|
||||||
|
* دستگاه یا مرورگر تورها دوباره از سر اجرا نشوند.
|
||||||
|
*/
|
||||||
|
export function useTourProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: TOURS_QUERY_KEY,
|
||||||
|
queryFn: () => api.get<ApiResponse<{ seen: SeenMap }>>('/api/v1/my/tours'),
|
||||||
|
// در طول یک نشست عوض نمیشود مگر با همین mutation، پس refetch بیفایده است.
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen: SeenMap = query.data?.data?.seen ?? {};
|
||||||
|
|
||||||
|
const markSeen = useMutation({
|
||||||
|
mutationFn: ({ tourId, version }: { tourId: string; version: number }) =>
|
||||||
|
api.post<ApiResponse<{ tourId: string; version: number }>>(`/api/v1/my/tours/${tourId}/seen`, { version }),
|
||||||
|
// کش را بدون رفتوبرگشت اضافه بهروز میکند؛ سرور همین مقدار را برمیگرداند.
|
||||||
|
onSuccess: (_data, { tourId, version }) => {
|
||||||
|
queryClient.setQueryData<ApiResponse<{ seen: SeenMap }>>(TOURS_QUERY_KEY, (prev) => {
|
||||||
|
const previous = prev?.data?.seen ?? {};
|
||||||
|
return {
|
||||||
|
...(prev ?? { success: true, errors: [] as never[] }),
|
||||||
|
data: { seen: { ...previous, [tourId]: Math.max(previous[tourId] ?? 0, version) } },
|
||||||
|
} as ApiResponse<{ seen: SeenMap }>;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
seen,
|
||||||
|
/** تا وقتی پاسخ سرور نیامده، هیچ توری خودکار اجرا نمیشود */
|
||||||
|
isReady: query.isSuccess,
|
||||||
|
isSeen: (tourId: string, version: number) => (seen[tourId] ?? 0) >= version,
|
||||||
|
markSeen: (tourId: string, version: number) => markSeen.mutate({ tourId, version }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { anchorSelector, resolveSteps } from './resolveSteps';
|
||||||
|
import { getTour, TOURS } from './registry';
|
||||||
|
import type { TourStep } from './types';
|
||||||
|
|
||||||
|
const STEPS: TourStep[] = [
|
||||||
|
{ anchor: 'one', title: 'یک', body: '…' },
|
||||||
|
{ anchor: 'two', title: 'دو', body: '…' },
|
||||||
|
{ anchor: 'three', title: 'سه', body: '…' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function mount(...anchors: string[]) {
|
||||||
|
document.body.innerHTML = anchors.map((a) => `<div data-tour="${a}"></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSteps', () => {
|
||||||
|
it('استپهای موجود را با حفظ ترتیب نگه میدارد', () => {
|
||||||
|
mount('three', 'one');
|
||||||
|
|
||||||
|
expect(resolveSteps(STEPS).map((s) => s.anchor)).toEqual(['one', 'three']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی هیچ المانی نیست، خروجی خالی است', () => {
|
||||||
|
expect(resolveSteps(STEPS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('آرایهٔ خالی خروجی خالی میدهد', () => {
|
||||||
|
mount('one');
|
||||||
|
|
||||||
|
expect(resolveSteps([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سلکتور از روی anchor ساخته میشود', () => {
|
||||||
|
expect(anchorSelector('appointments-stats')).toBe('[data-tour="appointments-stats"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registry', () => {
|
||||||
|
it('برای شناسهٔ ناشناس یا خالی null میدهد', () => {
|
||||||
|
expect(getTour('does-not-exist')).toBeNull();
|
||||||
|
expect(getTour(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('تور نوبتها ثبت شده است', () => {
|
||||||
|
expect(getTour('appointments')?.id).toBe('appointments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هر تور نسخهٔ معتبر و anchorهای بدون تکرار دارد', () => {
|
||||||
|
for (const [id, tour] of Object.entries(TOURS)) {
|
||||||
|
expect(tour.id).toBe(id);
|
||||||
|
expect(tour.version).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(tour.steps.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const anchors = tour.steps.map((s) => s.anchor);
|
||||||
|
expect(new Set(anchors).size).toBe(anchors.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { renderWithProviders } from '../test/utils';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||||
|
ApiError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const drive = vi.fn();
|
||||||
|
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||||
|
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||||
|
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import AppointmentsPage from './AppointmentsPage';
|
||||||
|
import { appointmentsTour } from '../lib/tour/tours/appointments';
|
||||||
|
import { resolveSteps } from '../lib/tour/resolveSteps';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/** پاسخهای ثابت صفحه؛ فقط وضعیت تور بین تستها فرق میکند. */
|
||||||
|
function mockApi(seen: Record<string, number>) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/my/tours')) return Promise.resolve({ success: true, data: { seen } });
|
||||||
|
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
|
||||||
|
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||||
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
drive.mockReset();
|
||||||
|
driverMock.mockClear();
|
||||||
|
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppointmentsPage — راهنمای صفحه', () => {
|
||||||
|
it('دکمهٔ راهنما کنار عنوان است', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای کاربری که تور را ندیده، خودکار اجرا میشود', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برای کاربری که تور را دیده، خودکار اجرا نمیشود', async () => {
|
||||||
|
mockApi({ appointments: appointmentsTour.version });
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await screen.findByText('این روز تعطیل است');
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
expect(drive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پزشک مستقل تب پزشکان ندارد، پس آن استپ از تور حذف میشود', async () => {
|
||||||
|
mockApi({});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(drive).toHaveBeenCalled(), { timeout: 3000 });
|
||||||
|
|
||||||
|
const anchors = driverMock.mock.calls[0][0].steps.map((s: { element: string }) => s.element);
|
||||||
|
expect(anchors).not.toContain('[data-tour="appointments-doctors"]');
|
||||||
|
expect(anchors).toContain('[data-tour="appointments-stats"]');
|
||||||
|
// شمارندهٔ تور همان تعداد استپِ واقعاً موجود است، نه کل تعریف
|
||||||
|
expect(anchors).toHaveLength(resolveSteps(appointmentsTour.steps).length);
|
||||||
|
expect(anchors.length).toBeLessThan(appointmentsTour.steps.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
|||||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||||
import TurnsTable from '../components/appointments/TurnsTable';
|
import TurnsTable from '../components/appointments/TurnsTable';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { useTour } from '../hooks/useTour';
|
||||||
|
import TourButton from '../components/ui/TourButton';
|
||||||
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
||||||
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
@@ -68,7 +70,7 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
<div ref={ref} data-tour="appointments-date" style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
||||||
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
||||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -417,14 +419,23 @@ export default function AppointmentsPage() {
|
|||||||
navigate(`/admin/appointments/${a.uuid}`);
|
navigate(`/admin/appointments/${a.uuid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// تور بار اول فقط وقتی راه میافتد که نوبتها آمده باشند؛ قبل از آن نیمی از
|
||||||
|
// المانهای هدف هنوز روی صفحه نیستند.
|
||||||
|
useTour('appointments', { ready: !apptQuery.isLoading });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<div style={{ padding: '20px 24px' }}>
|
||||||
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
||||||
{/* عنوان */}
|
{/* عنوان */}
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
|
||||||
|
<TourButton tourId="appointments" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* نوار آمار */}
|
{/* نوار آمار */}
|
||||||
|
<div data-tour="appointments-stats">
|
||||||
<TurnsStatInfo stats={stats} />
|
<TurnsStatInfo stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -454,13 +465,16 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div data-tour="appointments-view" style={{ display: 'flex' }}>
|
||||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
||||||
<button
|
<button
|
||||||
aria-label="فیلترها"
|
aria-label="فیلترها"
|
||||||
|
data-tour="appointments-filters"
|
||||||
className="btn sm"
|
className="btn sm"
|
||||||
onClick={() => setFiltersOpen(true)}
|
onClick={() => setFiltersOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
@@ -476,6 +490,7 @@ export default function AppointmentsPage() {
|
|||||||
{!isRepresentation && canCreateAppt && (
|
{!isRepresentation && canCreateAppt && (
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
|
data-tour="appointments-new"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
||||||
if (activeResource) { setBookingResource(activeResource); return; }
|
if (activeResource) { setBookingResource(activeResource); return; }
|
||||||
@@ -491,11 +506,12 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
||||||
<div style={{
|
<div data-tour="appointments-list" style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{showDoctorTabs && (
|
{showDoctorTabs && (
|
||||||
|
<div data-tour="appointments-doctors">
|
||||||
<DoctorTabs
|
<DoctorTabs
|
||||||
doctors={doctors.map(d => ({
|
doctors={doctors.map(d => ({
|
||||||
uuid: d.uuid,
|
uuid: d.uuid,
|
||||||
@@ -506,6 +522,7 @@ export default function AppointmentsPage() {
|
|||||||
onSelect={selectDoctor}
|
onSelect={selectDoctor}
|
||||||
showAll={isAdmin}
|
showAll={isAdmin}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
||||||
@@ -735,7 +752,7 @@ function ServiceFilterSelect({ value, options, onChange }: {
|
|||||||
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 280 }}>
|
<div data-tour="appointments-service" style={{ minWidth: 280 }}>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
||||||
value={value || null}
|
value={value || null}
|
||||||
|
|||||||
@@ -1096,3 +1096,50 @@ html, body { max-width: 100%; overflow-x: hidden; }
|
|||||||
[data-theme="dark"] .ck.ck-icon,
|
[data-theme="dark"] .ck.ck-icon,
|
||||||
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle, ellipse) { fill: currentColor; }
|
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle, ellipse) { fill: currentColor; }
|
||||||
[data-theme="dark"] .ck.ck-button.ck-on .ck-icon :is(path, polygon, rect, circle, ellipse) { fill: var(--primary); }
|
[data-theme="dark"] .ck.ck-button.ck-on .ck-icon :is(path, polygon, rect, circle, ellipse) { fill: var(--primary); }
|
||||||
|
|
||||||
|
/* ── تور راهنما ───────────────────────────────────────────────────────────
|
||||||
|
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-close-btn { color: var(--text-3); }
|
||||||
|
.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;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# User Profile API
|
# User Profile API
|
||||||
|
|
||||||
> **Prefix:** `/api/v1/user-profile`
|
> **Prefix:** `/api/v1/user-profile` (plus the per-user tour endpoints under `/api/v1/my/tours`)
|
||||||
|
|
||||||
User medical profiles store health information that can be shared with doctors.
|
User medical profiles store health information that can be shared with doctors.
|
||||||
|
|
||||||
@@ -186,3 +186,79 @@ The stored path is also returned as `avatar` in the profile GET/PATCH responses.
|
|||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `ERR_VALIDATION_001` | 422 | Missing or invalid file |
|
| `ERR_VALIDATION_001` | 422 | Missing or invalid file |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Guided tours (admin panel)
|
||||||
|
|
||||||
|
The admin SPA ships a step-by-step tour per page. A tour runs automatically the first time a
|
||||||
|
user opens that page, and is available afterwards from the `?` button in the page header.
|
||||||
|
Which tours a user has already been through is stored server-side, so it follows the account
|
||||||
|
across browsers and devices.
|
||||||
|
|
||||||
|
Each tour carries a `version` in the frontend registry (`assets/admin/lib/tour/tours/*.ts`).
|
||||||
|
The tour auto-runs again when its version is higher than the stored one, which is how a rewritten
|
||||||
|
tour reaches users who already saw the old text.
|
||||||
|
|
||||||
|
## GET `/api/v1/my/tours`
|
||||||
|
|
||||||
|
Tours the current user has already been through.
|
||||||
|
|
||||||
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { "seen": { "appointments": 1 } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`seen` is always an object; it is `{}` for a user who has not finished any tour yet.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `seen` | object | tour id → highest version the user has seen |
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST `/api/v1/my/tours/{tourId}/seen`
|
||||||
|
|
||||||
|
Record that the current user has been through a tour. Closing a tour part-way counts as seen —
|
||||||
|
the client sends this on tour close as well as on completion.
|
||||||
|
|
||||||
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `tourId` | string | Route-constrained to `[a-z0-9-]{1,64}`; anything else is a 404 |
|
||||||
|
|
||||||
|
### Request Body (`application/json`)
|
||||||
|
```json
|
||||||
|
{ "version": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `version` | integer ≥ 1 | ✅ | Version of the tour definition that was shown |
|
||||||
|
|
||||||
|
### Response `200`
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { "tourId": "appointments", "version": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat calls update the same row. The stored version only ever moves forward, so replaying an
|
||||||
|
old tour from the `?` button cannot re-trigger a newer one.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
```json
|
||||||
|
{ "success": false, "data": null, "errors": [ { "code": "ERR_VALIDATION_001", "message": "نسخهٔ راهنما باید عددی بزرگتر از صفر باشد", "field": "version" } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `ERR_VALIDATION_001` | 422 | `version` missing, not an integer, or below 1 |
|
||||||
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
|
| — | 404 | `tourId` does not match the route constraint |
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260810052544 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Per-user progress of the admin panel guided tours';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE user_tour_progress (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, tour_id VARCHAR(64) NOT NULL, version INT NOT NULL, seen_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_56700625D17F50A6 (uuid), INDEX IDX_56700625A76ED395 (user_id), UNIQUE INDEX uniq_user_tour (user_id, tour_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE user_tour_progress ADD CONSTRAINT FK_56700625A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE user_tour_progress DROP FOREIGN KEY FK_56700625A76ED395');
|
||||||
|
$this->addSql('DROP TABLE user_tour_progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+13742
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"altcha": "^3.2.0",
|
"altcha": "^3.2.0",
|
||||||
"ckeditor5": "^48.4.0",
|
"ckeditor5": "^48.4.0",
|
||||||
|
"driver.js": "^1.8.0",
|
||||||
"jalaali-js": "^1.2.8",
|
"jalaali-js": "^1.2.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ final class GlobalTables
|
|||||||
// هویت — یک شخص میتواند در چند محیط حضور داشته باشد
|
// هویت — یک شخص میتواند در چند محیط حضور داشته باشد
|
||||||
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records میآید',
|
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records میآید',
|
||||||
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
|
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
|
||||||
|
\App\UserProfile\Entity\UserTourProgress::class => 'راهنمای دیدهشدهٔ پنل به شخص وابسته است؛ کاربر با تعویض محیط دوباره تور نمیبیند',
|
||||||
\App\Auth\Entity\PreRegistration::class => 'پیشثبتنام، هنوز به هیچ محیطی وصل نیست',
|
\App\Auth\Entity\PreRegistration::class => 'پیشثبتنام، هنوز به هیچ محیطی وصل نیست',
|
||||||
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیینکنندهٔ محیط است؛ فیلتر کردنش حلقه میسازد',
|
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیینکنندهٔ محیط است؛ فیلتر کردنش حلقه میسازد',
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\UserProfile\Controller;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\UserProfile\Service\TourProgressService;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[OA\Tag(name: 'User Profile')]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
class TourProgressController extends BaseController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TourProgressService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/my/tours',
|
||||||
|
summary: 'Guided tours the current user has already been through',
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Map of tour id to the seen version'),
|
||||||
|
new OA\Response(response: 401, description: 'Not authenticated'),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[Route('/api/v1/my/tours', methods: ['GET'])]
|
||||||
|
public function seen(#[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
// An object even when empty, so the client never has to tell [] from {}.
|
||||||
|
return $this->success(['seen' => (object) $this->service->seenMap($user)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
path: '/api/v1/my/tours/{tourId}/seen',
|
||||||
|
summary: 'Record that the current user has been through a guided tour',
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
required: ['version'],
|
||||||
|
properties: [new OA\Property(property: 'version', type: 'integer', minimum: 1)]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Progress stored'),
|
||||||
|
new OA\Response(response: 401, description: 'Not authenticated'),
|
||||||
|
new OA\Response(response: 422, description: 'Invalid tour id or version'),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[Route('/api/v1/my/tours/{tourId}/seen', methods: ['POST'], requirements: ['tourId' => '[a-z0-9-]{1,64}'])]
|
||||||
|
public function markSeen(string $tourId, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = json_decode($request->getContent() ?: '{}', true);
|
||||||
|
$version = is_array($payload) ? ($payload['version'] ?? null) : null;
|
||||||
|
|
||||||
|
// Version drives whether a rewritten tour is shown again, so a bad value must
|
||||||
|
// not be silently coerced to 0 and hide the tour for good.
|
||||||
|
if (!is_int($version) || $version < 1) {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نسخهٔ راهنما باید عددی بزرگتر از صفر باشد', 422, 'version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->markSeen($user, $tourId, $version);
|
||||||
|
|
||||||
|
return $this->success(['tourId' => $tourId, 'version' => $version]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\UserProfile\Entity;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\UserProfile\Repository\UserTourProgressRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest version of an in-app guided tour a user has already been through.
|
||||||
|
*
|
||||||
|
* One row per (user, tour). Bumping a tour's version in the frontend registry makes
|
||||||
|
* the stored version stale, which is how a rewritten tour gets shown again once.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: UserTourProgressRepository::class)]
|
||||||
|
#[ORM\Table(name: 'user_tour_progress')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_user_tour', columns: ['user_id', 'tour_id'])]
|
||||||
|
class UserTourProgress
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||||
|
private string $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'tour_id', type: 'string', length: 64)]
|
||||||
|
private string $tourId;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $version;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'seen_at', type: 'integer')]
|
||||||
|
private int $seenAt;
|
||||||
|
|
||||||
|
public function __construct(User $user, string $tourId, int $version)
|
||||||
|
{
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
|
$this->user = $user;
|
||||||
|
$this->tourId = $tourId;
|
||||||
|
$this->version = $version;
|
||||||
|
$this->seenAt = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUuid(): string
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTourId(): string
|
||||||
|
{
|
||||||
|
return $this->tourId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVersion(): int
|
||||||
|
{
|
||||||
|
return $this->version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeenAt(): int
|
||||||
|
{
|
||||||
|
return $this->seenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only ever moves forward: replaying an older tour must not hide a newer one. */
|
||||||
|
public function markSeen(int $version): void
|
||||||
|
{
|
||||||
|
if ($version > $this->version) {
|
||||||
|
$this->version = $version;
|
||||||
|
}
|
||||||
|
$this->seenAt = time();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\UserProfile\Repository;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\UserProfile\Entity\UserTourProgress;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class UserTourProgressRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, UserTourProgress::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneForUser(User $user, string $tourId): ?UserTourProgress
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['user' => $user, 'tourId' => $tourId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int> tour id => highest version the user has seen
|
||||||
|
*/
|
||||||
|
public function seenMap(User $user): array
|
||||||
|
{
|
||||||
|
$rows = $this->createQueryBuilder('p')
|
||||||
|
->select('p.tourId AS tourId', 'p.version AS version')
|
||||||
|
->where('p.user = :user')
|
||||||
|
->setParameter('user', $user)
|
||||||
|
->getQuery()
|
||||||
|
->getArrayResult();
|
||||||
|
|
||||||
|
return array_column($rows, 'version', 'tourId');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\UserProfile\Service;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\UserProfile\Entity\UserTourProgress;
|
||||||
|
use App\UserProfile\Repository\UserTourProgressRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and records which admin panel guided tours a user has already been through.
|
||||||
|
*/
|
||||||
|
class TourProgressService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserTourProgressRepository $repository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int> tour id => highest version the user has seen
|
||||||
|
*/
|
||||||
|
public function seenMap(User $user): array
|
||||||
|
{
|
||||||
|
return $this->repository->seenMap($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markSeen(User $user, string $tourId, int $version): void
|
||||||
|
{
|
||||||
|
$progress = $this->repository->findOneForUser($user, $tourId);
|
||||||
|
|
||||||
|
if ($progress === null) {
|
||||||
|
$this->em->persist(new UserTourProgress($user, $tourId, $version));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress->markSeen($version);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\UserProfile;
|
||||||
|
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
use App\UserProfile\Entity\UserTourProgress;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
|
||||||
|
class TourProgressApiTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function testSeenListStartsEmptyAndGrowsAfterMarking(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', '/api/v1/my/tours', $user);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
self::assertSame([], (array) $body['data']['seen']);
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => 2]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', '/api/v1/my/tours', $user);
|
||||||
|
self::assertSame(['appointments' => 2], (array) $body['data']['seen']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkingTheSameTourAgainUpdatesTheRowInPlace(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => 1]);
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => 3]);
|
||||||
|
self::assertSame(200, $this->responseCode());
|
||||||
|
|
||||||
|
$rows = $this->em->getRepository(UserTourProgress::class)->findBy(['user' => $user]);
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(3, $rows[0]->getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnOlderVersionNeverLowersTheStoredOne(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => 5]);
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => 2]);
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', '/api/v1/my/tours', $user);
|
||||||
|
self::assertSame(['appointments' => 5], (array) $body['data']['seen']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeenListIsPrivateToTheUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
$other = $this->createUser();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/appointments/seen', $other, ['version' => 1]);
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', '/api/v1/my/tours', $user);
|
||||||
|
self::assertSame([], (array) $body['data']['seen']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBothEndpointsRequireAuthentication(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/api/v1/my/tours');
|
||||||
|
self::assertSame(401, $this->responseCode());
|
||||||
|
|
||||||
|
$this->client->request('POST', '/api/v1/my/tours/appointments/seen');
|
||||||
|
self::assertSame(401, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidVersions')]
|
||||||
|
public function testInvalidVersionIsRejected(mixed $version): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$body = $this->authJson('POST', '/api/v1/my/tours/appointments/seen', $user, ['version' => $version]);
|
||||||
|
|
||||||
|
self::assertSame(422, $this->responseCode());
|
||||||
|
self::assertSame('version', $body['errors'][0]['field']);
|
||||||
|
self::assertSame([], $this->em->getRepository(UserTourProgress::class)->findBy(['user' => $user]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidVersions(): iterable
|
||||||
|
{
|
||||||
|
yield 'zero' => [0];
|
||||||
|
yield 'string' => ['1'];
|
||||||
|
yield 'float' => [1.5];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownTourIdShapeIsNotRouted(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$this->authJson('POST', '/api/v1/my/tours/Appointments%20Page/seen', $user, ['version' => 1]);
|
||||||
|
|
||||||
|
self::assertSame(404, $this->responseCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\UserProfile;
|
||||||
|
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
use App\UserProfile\Entity\UserTourProgress;
|
||||||
|
use App\UserProfile\Repository\UserTourProgressRepository;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
|
||||||
|
class UserTourProgressTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function testSeenMapReturnsVersionPerTour(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$this->em->persist(new UserTourProgress($user, 'appointments', 1));
|
||||||
|
$this->em->persist(new UserTourProgress($user, 'patients', 3));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
/** @var UserTourProgressRepository $repo */
|
||||||
|
$repo = $this->em->getRepository(UserTourProgress::class);
|
||||||
|
|
||||||
|
self::assertSame(['appointments' => 1, 'patients' => 3], $repo->seenMap($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeenMapIsScopedToTheUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
$other = $this->createUser();
|
||||||
|
|
||||||
|
$this->em->persist(new UserTourProgress($other, 'appointments', 1));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
/** @var UserTourProgressRepository $repo */
|
||||||
|
$repo = $this->em->getRepository(UserTourProgress::class);
|
||||||
|
|
||||||
|
self::assertSame([], $repo->seenMap($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheSameTourCannotBeStoredTwiceForOneUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
|
||||||
|
$this->em->persist(new UserTourProgress($user, 'appointments', 1));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->em->persist(new UserTourProgress($user, 'appointments', 2));
|
||||||
|
|
||||||
|
$this->expectException(UniqueConstraintViolationException::class);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkSeenNeverLowersTheStoredVersion(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
$progress = new UserTourProgress($user, 'appointments', 4);
|
||||||
|
|
||||||
|
$progress->markSeen(2);
|
||||||
|
|
||||||
|
self::assertSame(4, $progress->getVersion());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user