diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js
new file mode 100644
index 0000000..0b8b622
--- /dev/null
+++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js
@@ -0,0 +1,105 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('@/services/response', () => ({
+ request: {
+ getServiceSlotsForReschedule: vi.fn(),
+ serviceReschedule: vi.fn(),
+ },
+}));
+
+import { request } from '@/services/response';
+import RescheduleModal from './RescheduleModal';
+
+const appointment = {
+ uuid: 'a-1',
+ doctor: { uuid: 'd-1' },
+ service_items: [{ uuid: 's-1' }, { uuid: 's-2' }],
+};
+
+const slots = {
+ data: {
+ total_duration_minutes: 30,
+ start_times: [
+ { start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' },
+ { start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' },
+ ],
+ },
+};
+
+async function openAndPickFirstDay(user) {
+ render();
+ await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
+
+ const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
+ await user.click(days[0]);
+}
+
+describe('مودال جابهجایی نوبت سایت', () => {
+ beforeEach(() => {
+ request.getServiceSlotsForReschedule.mockResolvedValue(slots);
+ request.serviceReschedule.mockResolvedValue({ data: {} });
+ });
+
+ /**
+ * ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» میبیند و نمیتواند
+ * حتی به همان روز جابهجا شود.
+ */
+ it('وقتها را با کنارگذاشتن نوبت فعلی میگیرد', async () => {
+ const user = userEvent.setup();
+ await openAndPickFirstDay(user);
+
+ await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled());
+
+ const [doctorUuid, , appointmentUuid, serviceUuids] =
+ request.getServiceSlotsForReschedule.mock.calls[0];
+
+ expect(doctorUuid).toBe('d-1');
+ expect(appointmentUuid).toBe('a-1');
+ expect(serviceUuids).toEqual(['s-1', 's-2']);
+ });
+
+ /** مدت را سرور حساب میکند؛ فرانت فقط زمان شروع میفرستد. */
+ it('فقط زمان شروع را میفرستد، نه مدت', async () => {
+ const onDone = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+ await user.click(screen.getByRole('button', { name: 'جابهجایی نوبت' }));
+
+ const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent));
+ await user.click(days[0]);
+
+ const slot = await screen.findByRole('button', { name: '09:00' });
+ await user.click(slot);
+ await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
+
+ await waitFor(() =>
+ expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }),
+ );
+ await waitFor(() => expect(onDone).toHaveBeenCalled());
+ });
+
+ it('روزِ بدون وقت آزاد را صریح میگوید', async () => {
+ request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } });
+ const user = userEvent.setup();
+
+ await openAndPickFirstDay(user);
+
+ expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument();
+ });
+
+ /** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */
+ it('پیام خطای سرور را نشان میدهد', async () => {
+ request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است'));
+ const user = userEvent.setup();
+
+ await openAndPickFirstDay(user);
+
+ await user.click(await screen.findByRole('button', { name: '09:00' }));
+ await user.click(screen.getByRole('button', { name: 'جابهجا کن' }));
+
+ expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument();
+ });
+});
diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js
new file mode 100644
index 0000000..7b02f54
--- /dev/null
+++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js
@@ -0,0 +1,90 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('@/services/response', () => ({
+ request: {
+ getCancellationPreview: vi.fn(),
+ cancelAppointment: vi.fn(),
+ },
+}));
+
+import { request } from '@/services/response';
+import ModalDeleteComment from './index';
+
+const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' };
+
+const preview = (over = {}) => ({
+ data: {
+ penalty_rials: 2_500_000,
+ deposit_refundable: true,
+ credit_refundable: true,
+ within_free_window: false,
+ notes: [],
+ paid_rials: 5_000_000,
+ ...over,
+ },
+});
+
+describe('مودال لغو نوبت سایت', () => {
+ beforeEach(() => {
+ request.cancelAppointment.mockResolvedValue({ data: {} });
+ });
+
+ /** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را میبست و هیچ درخواستی نمیرفت. */
+ it('پیش از تأیید، جریمه را از سرور نشان میدهد', async () => {
+ request.getCancellationPreview.mockResolvedValue(preview());
+ const user = userEvent.setup();
+
+ render();
+ await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
+
+ // ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط
+ // «مبلغ درست + واحد» سنجیده میشود نه شکل رقمها.
+ const amount = await screen.findByText(/تومان$/);
+ expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/);
+ expect(request.cancelAppointment).not.toHaveBeenCalled();
+ });
+
+ it('در بازهٔ رایگان «بدون جریمه» میگوید، نه صفر', async () => {
+ request.getCancellationPreview.mockResolvedValue(
+ preview({ penalty_rials: 0, within_free_window: true }),
+ );
+ const user = userEvent.setup();
+
+ render();
+ await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
+
+ expect(await screen.findByText('بدون جریمه')).toBeInTheDocument();
+ expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument();
+ });
+
+ it('تأیید، لغو را واقعاً میفرستد و والد را خبر میکند', async () => {
+ request.getCancellationPreview.mockResolvedValue(preview());
+ const onDone = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+ await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
+ await screen.findByText(/جریمهٔ لغو/);
+
+ await user.click(screen.getByRole('button', { name: 'لغو نوبت' }));
+
+ await waitFor(() =>
+ expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }),
+ );
+ await waitFor(() => expect(onDone).toHaveBeenCalled());
+ });
+
+ /** پیشنمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */
+ it('نبودِ پیشنمایش، لغو را قفل نمیکند', async () => {
+ request.getCancellationPreview.mockRejectedValue(new Error('down'));
+ const user = userEvent.setup();
+
+ render();
+ await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
+
+ expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
+ });
+});
diff --git a/lib/getStateInfo.test.js b/lib/getStateInfo.test.js
index f40cb1d..e78fefb 100644
--- a/lib/getStateInfo.test.js
+++ b/lib/getStateInfo.test.js
@@ -6,6 +6,12 @@ vi.mock('next/headers', () => ({
headers: async () => ({ get: (k) => (k === 'host' ? hostRef.value : null) }),
}));
+// هاستِ ناشناخته به API نمایندگی میرود؛ بدون این mock تست به شبکهٔ واقعی میزند و
+// بعد از پنج ثانیه timeout میشود — همان شکستِ قدیمیِ این فایل.
+vi.mock('@/lib/req', () => ({
+ fetchReq: async () => null,
+}));
+
import { getStateInfo } from '@/lib/getStateInfo';
beforeEach(() => {
diff --git a/tailwind.config.js b/tailwind.config.js
index 4515817..effef39 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -24,5 +24,17 @@ module.exports = {
},
},
plugins: [],
- darkMode: "class",
+ /**
+ * دو سازوکار تم همزمان در پروژه هست و هر دو باید کار کنند:
+ * صفحات عمومی با `data-theme="dark"` (از `next-themes` با `attribute="data-"`)
+ * و پنل با کلاس `.dark`.
+ *
+ * تا امروز فقط `class` تعریف شده بود، پس **همهٔ `dark:`های صفحات عمومی هرگز اجرا
+ * نمیشدند** — جریان رزرو در دارکمود سفید میماند. استراتژی `variant` هر دو را
+ * میپذیرد و هیچکدام را عوض نمیکند.
+ */
+ darkMode: [
+ "variant",
+ ["&:where(.dark, .dark *)", '&:where([data-theme="dark"], [data-theme="dark"] *)'],
+ ],
};