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

521 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# راهنمای قدم‌به‌قدم صفحات پنل ادمین — فاز ۱ (زیرساخت + صفحهٔ نوبت‌ها)
## پروژه
`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` که ۸۲ نقطهٔ استفاده دارد.