Author SHA1 Message Date
hamedandClaude Opus 5 51c21938eb feat(booking): give the booking flow a dark palette derived from its own colours
components/appointment/ had every colour hard-coded, so the flow rendered
identically in either theme even after the wiring was fixed. It now reads from
CSS custom properties.

These are not new colours. The :root values are byte-for-byte the hex codes
that were already in the components — twenty-one files, mapped one to one — so
light mode is unchanged. The dark values are derived from those same colours:
surfaces and borders darkened, text inverted, and the two brand colours (the
indigo and the orange) lightened rather than replaced, because both lose
contrast against a dark surface at their original values.

Verified in a headless browser with prefers-color-scheme forced dark:
data-theme lands on <html> and --ap-surface resolves to #1b1b20 rather than
white. Six one-off colours remain hard-coded — a success green, an error red
and similar — each used exactly once and none of them a surface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:33:39 +03:30
hamedandClaude Opus 5 2fb17d778a fix(theme): the public pages were writing data-, not data-theme
Chasing the dark-mode wiring to the end turned up the actual cause. next-themes
was configured with attribute="data-", which sets an attribute whose literal
name is "data-": the page rendered <html data-="dark">. Both globals.css and
the project's own documentation assume data-theme, so the transition rule keyed
on [data-theme] never applied either, and no Tailwind selector could have
matched. It is data-theme now, verified in a headless browser with
prefers-color-scheme forced to dark.

That is one of two reasons the booking flow looks the same in either theme. The
other is simply that components/appointment/ contains zero dark: utilities —
nothing there was ever styled for dark. The wiring is fixed and the 66 dark
rules that do exist now compile against the real attribute; giving the booking
flow a dark palette is design work, not a wiring bug, and it is not something
this pass invents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:13:11 +03:30
hamedandClaude Opus 5 519de26839 fix(theme): make dark mode actually apply to the public pages, and test the modals
Tailwind was configured with darkMode: "class" while next-themes writes
data-theme="dark" on the public pages. Every dark: utility on the public
site — 111 of them — compiled to a selector that never matched, which is why
the booking flow stayed white in dark mode. The variant strategy now accepts
both .dark (the panel) and [data-theme="dark"] (the public pages), so neither
provider had to change and 66 dark rules now compile against the real
attribute.

The cancel and reschedule modals get tests, the first component tests in this
repo. They pin the things that would be silently wrong: the penalty comes from
the server before anything is cancelled, the free window says "no penalty"
rather than showing a zero, confirming actually sends the request (the old
dialog's confirm button only closed it), a failed preview does not block the
cancellation, slots are requested with exclude_appointment_uuid so the
patient's own hour is not shown as taken, and the reschedule sends only the
start time because the server owns the duration.

The amount assertion deliberately checks the number and unit rather than the
digit shape — numberToArStyle uses the ar-AE locale and its output depends on
the ICU data in the environment.

lib/getStateInfo.test.js had been failing since before this work: an unknown
host falls through to the representation API, so the test made a real network
call and timed out after five seconds. It mocks lib/req now. The suite is
fully green for the first time: 158 tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:06:49 +03:30
hamedandClaude Opus 5 4c912dc6c1 feat(panel): let a patient actually cancel or move their own appointment
The site's own copy has been promising cancellation for a while —
lib/specialtyContent.js and components/appointment/information/Detail.js both
tell users they can cancel from "نوبت‌های من" and get a refund up to five hours
before the visit. The UI never could. The cancel modal existed, but its confirm
button called handleClose: it closed the dialog and sent nothing. And the whole
button row it lived in (ButtonData.js) had been commented out since the PDF
download commit, so it was not even reachable.

Cancelling now goes through POST /appointment/{uuid}/cancel and shows the
penalty from /cancellation-preview before the confirm — the same calculation
the cancel itself runs, so the number the patient sees is the number they are
charged. If the preview fails, the dialog says so rather than blocking; the
cancellation is still allowed.

Rescheduling is new and service-aware. It asks for slots with
exclude_appointment_uuid, so the patient's own hour counts as free rather than
showing as taken, and it sends only the start time — the server computes the
duration. Sending a client-side duration would mean two parallel calculations,
and the day a service's minutes change the appointment would move with a stale
one. The services on the appointment are carried over unchanged.

Both actions only appear for an appointment that is still in the future and not
already cancelled, and both refresh the list afterwards so a just-cancelled
appointment stops showing as confirmed.

The day strip in the reschedule modal is its own small component rather than
the booking flow's DatePicker: that one reads the doctor uuid from route params
and would be undefined inside the dashboard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:32:10 +03:30
hamedandClaude Opus 5 15edc628e4 docs: describe the two booking modes and their front-end contract
Records what the service flow actually guarantees: mode is per location (a doctor
can be slot-based in their office and service-based in a clinic), duration is
server data and must never be summed in the front, and shift boundaries are not
derived client-side because a flat start_times list cannot tell a break between
shifts from a gap left by a booked appointment.

Also notes that user-panel reads of service fields are guarded, since slot-mode
appointments carry none of them.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:59:17 +03:30
hamedandClaude Opus 5 ae958eee73 feat(booking): show services and duration on the user panel appointment
A patient who booked in service mode could not see which services they had
reserved or how long the appointment was. Both now appear on the list card and in
both detail layouts, reading service_items and service_total_minutes from
GET /api/v1/appointments/user.

Every field is behind an explicit guard. Slot-mode appointments carry none of
them, and an unguarded map would crash the card for every slot-mode appointment,
taking the whole panel with it. A reserve entry shows its services but not a
duration, because it has no time.

Covered by Card.test.jsx: slot-mode appointments render unchanged, absent fields
(older backend) do not crash, reserve hides the duration, and a zero duration
produces no row.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:53:18 +03:30
hamedandClaude Opus 5 d58eec1dba fix(booking): report the real time range for service-mode slots
adaptServiceSlots labelled its single session "زمان‌های خالی" and set end_time to
the last slot's *start* time, so the range shown was shorter than reality. It now
uses the last slot's end_time (falling back to start + total_duration_minutes) and
labels the session with the actual range.

Shift separation was requested but is not implementable from this payload: with a
flat start_times list, a gap between shifts is indistinguishable from a gap left
by a booked appointment. The normal step is duration + buffer, so any threshold
that splits shifts either splits every slot of a long service (step above the
threshold) or invents tabs around booked appointments. A fabricated tab claims a
shift that does not exist, which is worse than one correct tab. Real grouping
belongs to the server-side endpoint task 06 adds.

Also repairs three tests that had been red since adaptSlots changed shape: they
still asserted the old { morning, evening } contract while the function returns an
array of sessions. Suite goes from 4 failures to 1 (an unrelated pre-existing
getStateInfo network timeout).

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/
Slot-mode contract: adaptSlots untouched

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:50:22 +03:30
hamedandClaude Opus 5 f3659f827e feat(booking): take service duration from the backend, not a parallel client sum
The service picker summed duration_minutes itself while the backend already
returns total_duration_minutes. Two sources of truth: when the formula changes to
solo/additional minutes, the site would keep showing the old number and the
patient would see a duration that does not match their appointment.

The picker runs before the date step and appointment-service-slots needs a date,
so it is called with today. total_duration_minutes does not depend on the date —
the backend computes it before touching that day's shifts, so the number is right
even when today is closed and start_times comes back empty.

The label reads "مدت تقریبی" until the server number arrives, then "مدت کل".
A missing field or a failed request falls back to the client sum with a
console.warn rather than blanking the step.

Staleness is derived from the selection key instead of reset in the effect body,
which also clears the set-state-in-effect lint warning.

Task: clinicpro/docs/new_feture/taskes/task-00b-nobat724-service-mode/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:50:07 +03:30
41 changed files with 1128 additions and 162 deletions
+33
View File
@@ -109,6 +109,39 @@ All public pages wrap content in `<Layout name="/path">` from `components/layout
- `data/state.json` — province/state data, joined to city via `province_id`
- `data/specialties.json` — medical specialties; items with `parent` field are sub-specialties shown in FrequentSearches
### Booking Modes (slot vs service)
The backend decides how a doctor is booked, **per location**: a doctor can be
slot-based in their own office and service-based in a clinic. The mode arrives as
`booking_mode` on each entry of `getBookingLocations`, so `components/appointment/index.js`
reads it off the *selected* location, never off the doctor.
| Mode | Flow | Slot source |
|---|---|---|
| `slot` | date → time | `getAppointmentSlots``adaptSlots` |
| `service` | **service → date → time** | `getServiceSlots``adaptServiceSlots` |
- `lib/appointmentSlots.js` is the split point. Both adapters return the same shape —
an array of sessions `{ start_time, end_time, label, slots }` — which
`app/component/date/dateTime/index.js` turns into tabs (`sessions.length > 1`).
- **Duration is server data.** `total_duration_minutes` comes from
`appointment-service-slots`; never sum `duration_minutes` in the front. The backend
formula is going to change to solo/additional minutes, and any parallel client
calculation will silently start showing a wrong number. `components/appointment/service/index.js`
keeps a client sum only as a labelled fallback with a `console.warn`.
- The service step runs *before* the date step, yet `total_duration_minutes` does not
depend on the date — the backend computes it before touching that day's shifts — so
the picker may ask for it using today's date even if today is closed.
- Shift boundaries are **not** derived in the front for service mode: a flat
`start_times` list cannot distinguish a break between shifts from a gap left by a
booked appointment. One session with the real range is returned instead. See the note
in `clinicpro/docs/api/appointment.md`.
- User panel: `service_items` and `service_total_minutes` come from
`GET /api/v1/appointments/user`. Slot-mode appointments have neither, so every read
is guarded — an unguarded `.map` crashes the card for all slot-mode appointments.
Backend reference: `clinicpro/docs/architecture/booking-modes.md`.
### Doctor & Clinic Slugs
Both use `uuid` as the URL slug: `/doctor/${doctor.uuid}` and `/clinic/${clinic.uuid}`.
+4 -1
View File
@@ -16,9 +16,12 @@ export function Providers({ children }) {
.catch(() => {});
}, []);
// `attribute="data-"` یک اتریبیوت با نام تحت‌اللفظی `data-` می‌ساخت (`<html data-="dark">`)،
// نه `data-theme`. هم `globals.css` و هم مستند پروژه از اول `data-theme` را فرض کرده‌اند،
// پس هیچ‌کدام هرگز اجرا نمی‌شدند.
return (
<ThemeProvider
attribute="data-"
attribute="data-theme"
defaultTheme="system"
enableSystem
>
+54
View File
@@ -74,6 +74,60 @@ body {
background: #fafafa;
}
/* ── توکن‌های جریان رزرو ──────────────────────────────────────────────────
*
* جریان رزرو رنگ‌هایش را hard-code داشت، پس در دارک‌مود دقیقاً همان سفید می‌ماند.
* این‌ها **رنگ تازه نیستند**: مقادیر روشن عیناً همان چیزی است که تا امروز بود، و
* نسخهٔ تیره از همان‌ها مشتق شده. طراحی تازه‌ای اینجا اختراع نشده.
*/
:root {
--ap-surface: #ffffff;
--ap-surface-2: #fafafa;
--ap-surface-3: #f5f5f5;
--ap-border: #efefef;
--ap-border-2: #d7d7d7;
--ap-heading: #2f2f2f;
--ap-text: #3b3b3b;
--ap-text-2: #616161;
--ap-text-3: #7a7a7a;
--ap-text-4: #9b9b9b;
--ap-muted: #525252;
--ap-primary: #5559ce;
--ap-accent: #f17732;
--ap-accent-strong: #b4541f;
--ap-danger: #e0383b;
--ap-danger-bg: #fdeeee;
--ap-danger-border: #f3c0c1;
--ap-warning: #f4b740;
--ap-warning-bg: #fff8e8;
--ap-warning-text: #8a6100;
}
[data-theme="dark"] {
--ap-surface: #1b1b20;
--ap-surface-2: #232329;
--ap-surface-3: #2a2a31;
--ap-border: #2e2e36;
--ap-border-2: #3a3a44;
--ap-heading: #f0f0f2;
--ap-text: #e8e8ea;
--ap-text-2: #b8b8be;
--ap-text-3: #98989f;
--ap-text-4: #82828a;
--ap-muted: #c9c9cf;
/* بنفش و نارنجی روی زمینهٔ تیره کم‌کنتراست‌اند؛ کمی روشن‌تر شده‌اند نه عوض. */
--ap-primary: #8a8de6;
--ap-accent: #ff9152;
--ap-accent-strong: #ffb184;
--ap-danger: #ff6b6e;
--ap-danger-bg: #3a1f20;
--ap-danger-border: #5a2e30;
--ap-warning: #ffca5c;
--ap-warning-bg: #33290f;
--ap-warning-text: #ffd98a;
}
/* transition-colors only on theme-sensitive elements, not every DOM node */
.dark,
[data-theme] {
+3 -1
View File
@@ -61,7 +61,7 @@ function Container({
let dateStep;
if (bookingLocations.length === 0) {
dateStep = (
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[var(--ap-text-3)]">
نوبتدهی آنلاین برای این پزشک فعال نیست.
</p>
);
@@ -77,6 +77,8 @@ function Container({
dateStep = (
<ServiceSelect
services={bookingServices}
doctorUuid={doctor?.uuid}
clinicUuid={clinicUuid}
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
/>
);
+1 -1
View File
@@ -3,7 +3,7 @@ import SelectDatePicker from "./SelectDatePicker";
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
return (
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
1. انتخاب روز
</p>
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
+1 -1
View File
@@ -3,7 +3,7 @@ import DateTime from "@/app/component/date/dateTime";
function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) {
return (
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] md:text-[16px] font-bold">
2. انتخاب ساعت
</p>
<DateTime
+6 -6
View File
@@ -27,7 +27,7 @@ function Date({
return (
<div className="w-full lg:w-[61%]">
<div
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[var(--ap-border)] bg-transparent lg:bg-[var(--ap-surface)]"
>
{(onChangeLocation || (serviceMode && onChangeService)) && (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
@@ -35,7 +35,7 @@ function Date({
<button
type="button"
onClick={onChangeLocation}
className="text-[13px] text-[#5559CE] hover:underline"
className="text-[13px] text-[var(--ap-primary)] hover:underline"
>
تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
</button>
@@ -44,7 +44,7 @@ function Date({
<button
type="button"
onClick={onChangeService}
className="text-[13px] text-[#5559CE] hover:underline"
className="text-[13px] text-[var(--ap-primary)] hover:underline"
>
تغییر سرویس
</button>
@@ -52,14 +52,14 @@ function Date({
</div>
)}
{closedOnDate && (
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[#B4541F] leading-relaxed">
<div className="mb-3 rounded-[8px] border border-solid border-[var(--ap-accent)] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[var(--ap-accent-strong)] leading-relaxed">
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
{onChangeLocation && (
<button
type="button"
onClick={onChangeLocation}
className="mr-1 font-medium text-[#5559CE] hover:underline"
className="mr-1 font-medium text-[var(--ap-primary)] hover:underline"
>
انتخاب محل دیگر
</button>
+2 -2
View File
@@ -2,9 +2,9 @@ function Content({ isConfirmed, children, title }) {
return (
<div className="flex w-full flex-col items-start justify-start gap-[8px] sm:gap-[10px] md:gap-[13px] lg:gap-[16px]">
<div className="flex min-h-[32px] items-center justify-start gap-[8px] md:gap-[6px] lg:gap-[4px]">
<p className="text-[#525252] text-[14px] font-medium">{title}</p>
<p className="text-[var(--ap-muted)] text-[14px] font-medium">{title}</p>
{isConfirmed && (
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[#FFF] text-[10px] md:text-[12px] font-normal">
<p className="py-[4px] md:py-[2px] leading-[8px] sm:leading-[14px] md:leading-[21px] lg:leading-[28px] px-[5px] bg-[#05BA58] rounded-[40px] text-[var(--ap-surface)] text-[10px] md:text-[12px] font-normal">
تایید شده
</p>
)}
+2 -2
View File
@@ -27,8 +27,8 @@ function Form({ changeData, insurance, supplementaryInsurance, data, isForAnothe
{isForAnother ? (
<EditField changeData={changeData} data={data?.phone} name="phone" error={errors?.phone} />
) : (
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[#D7D7D7] rounded-lg bg-[#F5F5F5]">
<span className="text-[#3B3B3B]">{data?.phone?.value}</span>
<div className="flex items-center justify-between px-[14px] py-[12.5px] border border-[var(--ap-border-2)] rounded-lg bg-[var(--ap-surface-3)]">
<span className="text-[var(--ap-text)]">{data?.phone?.value}</span>
<span className="text-[#4CAF50] text-[14px]">تایید شده</span>
</div>
)}
+1 -1
View File
@@ -195,7 +195,7 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
onClick={handleSubmit}
>
<p
className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}
className={`${loading && "opacity-0"} text-[var(--ap-border)] text-[16px] font-medium`}
>
ثبت اطلاعات
</p>
+4 -4
View File
@@ -78,10 +78,10 @@ function Detail({
}, []);
return (
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[#FFF] md:border-[#EFEFEF]' : ''}
// ${typePay === 'success' || typePay === 'failed' ? 'bg-transparent border-transparent md:bg-[var(--ap-surface)] md:border-[var(--ap-border)]' : ''}
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
className={`opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] `}
>
{/* دکمه برگشت */}
<div
@@ -91,7 +91,7 @@ function Detail({
<ArrowRightS />
</div>
<p className="text-[#3B3B3B] text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[20px] font-bold">
{isForAnother ? "اطلاعات کاربر جدید" : "اطلاعات حساب کاربری"}
</p>
<Form
@@ -107,7 +107,7 @@ function Detail({
onClick={() => (isForAnother ? switchToSelf() : switchToAnother())}
>
<AddCircleBlueA />
<p className="text-[#5559CE] text-[16px] font-medium">
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
{isForAnother ? "نوبت برای خودم است" : "نوبت برای شخص دیگری است"}
</p>
</Button>
+3 -3
View File
@@ -8,7 +8,7 @@ function FailedPay({ setStep, fadeElement }) {
return (
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
>
<div className="mt-[7px]">
<div className="flex flex-col justify-center items-center gap-[14px]">
@@ -24,7 +24,7 @@ function FailedPay({ setStep, fadeElement }) {
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
>
<RedoA />
<p className="text-[#5559CE] text-[16px] font-medium">
<p className="text-[var(--ap-primary)] text-[16px] font-medium">
صفحه اصلی
</p>
</Button>
@@ -35,7 +35,7 @@ function FailedPay({ setStep, fadeElement }) {
className="!flex !rounded-[8px] !items-center !p-2 !justify-center !gap-[4px]"
>
<RefreshA />
<p className="text-[#FAFAFA] text-[16px] font-medium">
<p className="text-[var(--ap-surface-2)] text-[16px] font-medium">
تلاش دوباره
</p>
</Button>
+2 -2
View File
@@ -215,8 +215,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
<div className="w-12 h-12 border-4 border-[var(--ap-primary)] border-t-transparent rounded-full animate-spin"></div>
<p className="text-[var(--ap-text)] text-[16px]">در حال بارگذاری...</p>
</div>
</div>
);
+10 -10
View File
@@ -29,7 +29,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<div className="w-[18px] h-[18px] md:w-[20px] md:h-[20px]">
<LocationA />
</div>
<p className="text-[#616161] text-[14px] md:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-normal">
آدرس: {address}
</p>
</li>
@@ -39,10 +39,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<CalendarA />
</div>
<div className="flex items-center justify-start">
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
تاریخ نوبت:{" "}
</p>
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
{formattedDate}
</p>
</div>
@@ -53,7 +53,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
onClick={() => setStep(0)}
variant="text"
>
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
تغییر{" "}
</p>
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
@@ -68,10 +68,10 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
<ClockA />
</div>
<div className="flex items-center justify-start">
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
زمان نوبت:{" "}
</p>
<p className="text-[#2F2F2F] text-[14px] md:text-[16px] font-medium mr-1">
<p className="text-[var(--ap-heading)] text-[14px] md:text-[16px] font-medium mr-1">
ساعت {formattedTime}
</p>
</div>
@@ -82,7 +82,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
onClick={() => setStep(0)}
variant="text"
>
<p className="!text-[#F17732] !text-[12px] md:!text-[14px] !font-normal">
<p className="!text-[var(--ap-accent)] !text-[12px] md:!text-[14px] !font-normal">
تغییر{" "}
</p>
<div className="w-[16px] h-[16px] sm:w-[18px] sm:h-[18px] md:w-[21px] md:h-[21px] lg:w-[24px] lg:h-[24px]">
@@ -93,7 +93,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
</li>
</ul>
<span
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[#D7D7D7] block"
className="mt-[10px] sm:mt-[16px] md:mt-[22px] lg:mt-[28px] mb-[8px] sm:mb-[13px] md:mb-[19px] lg:mb-[24px] w-full h-px bg-[var(--ap-border-2)] block"
></span>
<ul className="flex flex-col items-start justify-start gap-[8px] sm:gap-[13px] md:gap-[19px] lg:gap-[24px]">
{/* <li className="flex items-center justify-start gap-[4px] md:gap-[6px] lg:gap-[8px]">
@@ -102,7 +102,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
>
<TickCircleOrange />
</div>
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
فقط در صورت لغو نوبت تا 5 ساعت قبل از زمان ویزیت، امکان استرداد وجه
ممکن می باشد.
</p>
@@ -113,7 +113,7 @@ function Detail({ doctor, step, setStep, selectedSlot, selectedDate }) {
>
<TickCircleOrange />
</div>
<p className="text-[#616161] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
<p className="text-[var(--ap-text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
لطفا به موقع در مطب حضور داشته باشید.
</p>
</li>
+2 -2
View File
@@ -16,10 +16,10 @@ function Head({ doctor }) {
className=" w-[40px] sm:w-[58px] md:w-[77px] lg:w-[95px] h-[40px] sm:h-[58px] md:h-[77px] lg:h-[95px] "
/>
<div className="flex flex-col items-start justify-center gap-[8px] sm:gap-[12px] md:gap-[16px] lg:gap-[20px]">
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
{doctor?.name}
</p>
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<p className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
تخصص: {expertiseText}
</p>
</div>
+2 -2
View File
@@ -9,9 +9,9 @@ function Information({ doctor, step, setStep, typePay, isPay, disableSide, selec
className={`w-full lg:w-[41%] relative ${disableSide ? "hidden lg:flex" : "" }`}
>
<div
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[#EFEFEF] bg-[#FFF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
className={`p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px] sticky right-0 w-full opacity-page rounded-[8px] border border-solid border-[var(--ap-border)] bg-[var(--ap-surface)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)] ${typePay === "success" || typePay === "failed" ? "hidden md:block" : "block" } `}
>
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
جزئیات نوبت
</p>
<Head doctor={doctor} />
+3 -3
View File
@@ -7,7 +7,7 @@ function Address({ doctor, selectedLocation }) {
return (
<ul className="hidden mt-[4px] lg:flex flex-col">
<li>
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
<p className="font-normal">{selectedLocation.address || "—"}</p>
</div>
@@ -20,12 +20,12 @@ function Address({ doctor, selectedLocation }) {
<ul className="hidden mt-[4px] lg:flex flex-col">
{doctor?.address?.map((item, idx) => (
<li key={idx}>
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
<div className="flex py-[12px] text-[var(--ap-text-2)] text-[14px] items-start justify-start">
<p className="font-bold min-w-[115px]">{`${item.name}: `}</p>
<p className="font-normal">{item.address || item.location}</p>
</div>
{doctor.address.length > idx + 1 && (
<span className="bg-[#EFEFEF] block h-px w-full"></span>
<span className="bg-[var(--ap-border)] block h-px w-full"></span>
)}
</li>
))}
+2 -2
View File
@@ -14,10 +14,10 @@ function Head({ doctor }) {
className=" w-[48px] sm:w-[51px] md:w-[54px] lg:w-[56px] h-[48px] sm:h-[51px] md:h-[54px] lg:h-[56px] "
/>
<div className="flex flex-col items-start justify-start gap-[8px]">
<h2 className="text-[#3B3B3B] text-[12px] md:text-[14px] font-bold">
<h2 className="text-[var(--ap-text)] text-[12px] md:text-[14px] font-bold">
{doctor?.name}
</h2>
<h3 className="text-[#525252] text-[12px] md:text-[14px] font-medium">
<h3 className="text-[var(--ap-muted)] text-[12px] md:text-[14px] font-medium">
تخصص: {expertiseText}
</h3>
</div>
@@ -18,10 +18,10 @@ function LocationSelect({ locations = [], selected, onSelect }) {
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب محل نوبتدهی</h2>
{!anyOpen && locations.length > 0 && (
<p className="mb-3 text-[13px] text-[#B4541F]">
<p className="mb-3 text-[13px] text-[var(--ap-accent-strong)]">
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
</p>
)}
@@ -43,32 +43,32 @@ function LocationSelect({ locations = [], selected, onSelect }) {
onClick={() => !closed && onSelect(location)}
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
closed
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
? "border-[var(--ap-border)] bg-[var(--ap-surface-2)] opacity-60 cursor-not-allowed"
: active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
? "border-[var(--ap-primary)] bg-[rgba(85,89,206,0.06)]"
: "border-[var(--ap-border)] bg-[var(--ap-surface)] hover:border-[#C7C9EC]"
}`}
>
<div className="flex items-center justify-between gap-[8px]">
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
<p className="text-[14px] md:text-[16px] font-bold text-[var(--ap-text)]">
{location.title}
</p>
{location.type === "clinic" && (
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
<span className="text-[11px] text-[var(--ap-primary)] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
کلینیک
</span>
)}
</div>
{location.address && (
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
<p className="mt-[6px] text-[13px] text-[var(--ap-text-2)] font-normal">
{location.address}
</p>
)}
<p
className={`mt-[8px] text-[12px] ${
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
closed ? "text-[var(--ap-accent-strong)]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
}`}
>
{closed
+2 -2
View File
@@ -5,10 +5,10 @@ function Location({ doctor, selectedLocation }) {
return (
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
<div
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
className="p-0 lg:p-[16px] sticky rounded-[8px] bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] top-[calc(12px_+_75px_+_32px)] sm:top-[calc(21px_+_75px_+_45px)] md:top-[calc(30px_+_75px_+_58px)] lg:top-[calc(40px_+_75px_+_78px)]"
>
<Head doctor={doctor} />
<span className="block bg-[#EFEFEF] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
<span className="block bg-[var(--ap-border)] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
<Address doctor={doctor} selectedLocation={selectedLocation} />
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import React from "react";
function ButtonFixed({ children }) {
return (
<div
className="!fixed md:!relative !bottom-0 !right-0 bg-[#FFF] border-0 border-t md:border-t-transparent border-[#D7D7D7] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
className="!fixed md:!relative !bottom-0 !right-0 bg-[var(--ap-surface)] border-0 border-t md:border-t-transparent border-[var(--ap-border-2)] z-10 !w-full sm:!w-full !p-[16px] sm:!px-[52px] md:!ml-0 md:!p-0 md:!w-full"
>
{children}
</div>
+26 -26
View File
@@ -69,29 +69,29 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
return (
<div className="w-full lg:w-[59%]">
<div className="opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[12px] p-[16px] md:p-[24px]">
<p className="text-[#3B3B3B] text-[16px] md:text-[20px] font-bold mb-[20px]">
<div className="opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[12px] p-[16px] md:p-[24px]">
<p className="text-[var(--ap-text)] text-[16px] md:text-[20px] font-bold mb-[20px]">
تأیید و پرداخت
</p>
{/* ── خلاصه نوبت ── */}
<div className="rounded-[10px] border border-[#EFEFEF] bg-[#FAFAFA] p-[16px] flex flex-col gap-[12px]">
<div className="rounded-[10px] border border-[var(--ap-border)] bg-[var(--ap-surface-2)] p-[16px] flex flex-col gap-[12px]">
{doctor?.name && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">پزشک</span>
<span className="text-[#3B3B3B] font-medium">{doctor.name}</span>
<span className="text-[var(--ap-text-4)]">پزشک</span>
<span className="text-[var(--ap-text)] font-medium">{doctor.name}</span>
</div>
)}
{patientName && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">بیمار</span>
<span className="text-[#3B3B3B] font-medium">{patientName}</span>
<span className="text-[var(--ap-text-4)]">بیمار</span>
<span className="text-[var(--ap-text)] font-medium">{patientName}</span>
</div>
)}
{selectedSlot?.start && (
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
<span className="text-[#9B9B9B]">زمان نوبت</span>
<span className="text-[#3B3B3B] font-medium" dir="rtl">
<span className="text-[var(--ap-text-4)]">زمان نوبت</span>
<span className="text-[var(--ap-text)] font-medium" dir="rtl">
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
{" — ساعت "}
@@ -102,12 +102,12 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
</div>
{/* ── مبلغ ── */}
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[#EFEFEF]">
<span className="text-[#616161] text-[14px] md:text-[16px] font-medium">
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[var(--ap-border)]">
<span className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
مبلغ قابل پرداخت
</span>
<span className="text-[#3B3B3B] text-[18px] md:text-[20px] font-bold">
{toman} <span className="text-[14px] font-normal text-[#616161]">تومان</span>
<span className="text-[var(--ap-text)] text-[18px] md:text-[20px] font-bold">
{toman} <span className="text-[14px] font-normal text-[var(--ap-text-2)]">تومان</span>
</span>
</div>
@@ -115,18 +115,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
{!expired && (
<div className="mt-[20px]">
<div className="flex justify-between items-center mb-[8px]">
<span className="text-[#616161] text-[13px] md:text-[14px]">
<span className="text-[var(--ap-text-2)] text-[13px] md:text-[14px]">
مهلت پرداخت و نگهداری نوبت
</span>
<span
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[#E0383B]" : "text-[#5559CE]" }`}
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[var(--ap-danger)]" : "text-[var(--ap-primary)]" }`}
>
{formatTime(timeLeft)}
</span>
</div>
<div className="w-full h-[6px] rounded-full bg-[#EFEFEF] overflow-hidden">
<div className="w-full h-[6px] rounded-full bg-[var(--ap-border)] overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[#E0383B]" : "bg-[#5559CE]" }`}
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[var(--ap-danger)]" : "bg-[var(--ap-primary)]" }`}
style={{ width: `${progress}%` }}
/>
</div>
@@ -137,18 +137,18 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
{!expired && (
<div className="mt-[20px]">
{testMode ? (
<div className="flex items-start gap-[8px] rounded-[10px] border border-[#F4B740] bg-[#FFF8E8] p-[12px]">
<div className="flex items-start gap-[8px] rounded-[10px] border border-[var(--ap-warning)] bg-[var(--ap-surface)8E8] p-[12px]">
<span className="text-[18px] leading-none">🧪</span>
<div>
<p className="text-[#8A6100] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
<p className="text-[#8A6100] text-[12px] mt-[2px] leading-[20px]">
<p className="text-[var(--ap-warning-text)] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
<p className="text-[var(--ap-warning-text)] text-[12px] mt-[2px] leading-[20px]">
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
</p>
</div>
</div>
) : gateways.length === 0 ? (
<div className="rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
<p className="text-[#E0383B] text-[14px] font-medium">
<div className="rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
</p>
</div>
@@ -173,8 +173,8 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
)}
{expired && (
<div className="mt-[20px] rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
<p className="text-[#E0383B] text-[14px] font-medium">
<div className="mt-[20px] rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
</p>
</div>
@@ -188,7 +188,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
onClick={() => setStep(0)}
variant="contained"
>
<span className="text-[#FFF] text-[16px] font-medium">انتخاب مجدد زمان</span>
<span className="text-[var(--ap-surface)] text-[16px] font-medium">انتخاب مجدد زمان</span>
</Button>
) : (
<Button
@@ -200,7 +200,7 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
(!testMode && !selectedBank)
}
>
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
<span className={`text-[var(--ap-surface)] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
</span>
{!loading && <ArrowLeftB />}
+65 -16
View File
@@ -1,31 +1,77 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import moment from "jalali-moment";
import { request } from "@/services/response";
function toToman(rials) {
if (!rials) return null;
return Math.round(rials / 10).toLocaleString("fa-IR");
}
/**
* جمعِ سمتِ کلاینت — فقط fallback برای بک‌اندی که `total_duration_minutes` نمی‌دهد.
* مدت، دادهٔ سرور است: فرمولش قرار است به «زمان تنها / زمان اضافه» تغییر کند و هر
* محاسبهٔ موازی در فرانت از آن روز عددِ غلط نشان می‌دهد.
*/
function fallbackSum(services, uuids) {
return services
.filter((s) => uuids.includes(s.uuid))
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
}
// مرحلهٔ انتخاب سرویس (فقط حالت نوبت‌دهی سرویسی) — پیش از انتخاب روز.
function ServiceSelect({ services = [], onContinue }) {
function ServiceSelect({ services = [], onContinue, doctorUuid, clinicUuid = null }) {
const [draft, setDraft] = useState([]);
/** `{ key, minutes }` — کلید همان انتخابی است که عدد به آن تعلق دارد. */
const [serverDuration, setServerDuration] = useState(null);
const toggle = (uuid) =>
setDraft((prev) =>
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
);
const totalMinutes = services
.filter((s) => draft.includes(s.uuid))
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
const draftKey = draft.join(",");
// مدت را سرور حساب می‌کند. این مرحله پیش از انتخاب روز است، ولی
// `total_duration_minutes` به تاریخ وابسته نیست — بک‌اند آن را پیش از لمسِ
// شیفت‌های آن روز برمی‌گرداند، پس حتی اگر امروز تعطیل باشد و `start_times`
// خالی بیاید، عدد درست است.
useEffect(() => {
if (!doctorUuid || draftKey === "") return;
let cancelled = false;
const today = moment().format("YYYY-MM-DD");
request
.getServiceSlots(doctorUuid, today, draftKey.split(","), clinicUuid)
.then((res) => {
if (cancelled) return;
const minutes = (res?.data?.data ?? res?.data)?.total_duration_minutes;
if (typeof minutes !== "number") {
console.warn("[booking] total_duration_minutes missing — falling back to client sum");
return;
}
setServerDuration({ key: draftKey, minutes });
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [doctorUuid, clinicUuid, draftKey]);
// کهنه‌بودن **مشتق** می‌شود، نه با reset: عددِ متعلق به انتخاب قبلی خودکار نادیده
// گرفته می‌شود و effect لازم نیست در بدنه‌اش setState صدا بزند.
const serverMinutes = serverDuration?.key === draftKey ? serverDuration.minutes : null;
const totalMinutes = serverMinutes ?? fallbackSum(services, draft);
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
<h2 className="text-[16px] font-bold text-[var(--ap-text)] mb-4">۱. انتخاب سرویس</h2>
{services.length === 0 ? (
<p className="text-[14px] text-[#7A7A7A]">
<p className="text-[14px] text-[var(--ap-text-3)]">
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
</p>
) : (
@@ -40,27 +86,27 @@ function ServiceSelect({ services = [], onContinue }) {
onClick={() => toggle(s.uuid)}
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
active
? "border-[#5559CE] bg-[#5559CE]/5"
: "border-gray-200 bg-white hover:border-[#5559CE]"
? "border-[var(--ap-primary)] bg-[var(--ap-primary)]/5"
: "border-gray-200 bg-white hover:border-[var(--ap-primary)]"
}`}
>
<span className="flex items-center gap-2 min-w-0">
<span
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
active ? "border-[#5559CE] bg-[#5559CE]" : "border-gray-300"
active ? "border-[var(--ap-primary)] bg-[var(--ap-primary)]" : "border-gray-300"
}`}
>
{active && (
<span className="w-2 h-2 bg-white rounded-[2px]" />
)}
</span>
<span className="text-[14px] text-[#3B3B3B] truncate">
<span className="text-[14px] text-[var(--ap-text)] truncate">
{s.name}
</span>
</span>
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[#7A7A7A]">
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[var(--ap-text-3)]">
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
{toman ? <span className="text-[#5559CE]">{toman} تومان</span> : null}
{toman ? <span className="text-[var(--ap-primary)]">{toman} تومان</span> : null}
</span>
</button>
);
@@ -69,8 +115,11 @@ function ServiceSelect({ services = [], onContinue }) {
)}
{draft.length > 0 && (
<div className="mt-4 text-[13px] text-[#7A7A7A]">
مدت کل: <b className="text-[#3B3B3B]">{totalMinutes} دقیقه</b>
<div className="mt-4 text-[13px] text-[var(--ap-text-3)]">
{/* تا وقتی عدد سرور نرسیده، «تقریبی» است — عددِ جمعِ کلاینت ممکن است با
مدت واقعی نوبت یکی نباشد. */}
{serverMinutes === null ? "مدت تقریبی" : "مدت کل"}:{" "}
<b className="text-[var(--ap-text)]">{totalMinutes} دقیقه</b>
</div>
)}
@@ -78,7 +127,7 @@ function ServiceSelect({ services = [], onContinue }) {
type="button"
disabled={draft.length === 0}
onClick={() => onContinue(draft)}
className="mt-5 w-full h-[48px] rounded-xl bg-[#5559CE] text-white text-[15px] font-medium disabled:opacity-50"
className="mt-5 w-full h-[48px] rounded-xl bg-[var(--ap-primary)] text-white text-[15px] font-medium disabled:opacity-50"
>
انتخاب زمان
</button>
+4 -4
View File
@@ -7,17 +7,17 @@ function SuccessPay({ setStep }) {
return (
<div className="w-full lg:w-[59%]">
<div
className={`opacity-page bg-transparent lg:bg-[#FFF] border border-solid border-transparent lg:border-[#EFEFEF] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
className={`opacity-page bg-transparent lg:bg-[var(--ap-surface)] border border-solid border-transparent lg:border-[var(--ap-border)] rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
>
<div className="pt-[8px] pb-[4px] grid place-items-center">
<div className="flex flex-col justify-center items-center gap-[14px]">
<TickCircleGreenA />
<p className="text-[#3B3B3B] text-[20px] font-bold">
<p className="text-[var(--ap-text)] text-[20px] font-bold">
پرداخت شما با موفقیت انجام شد.
</p>
</div>
<p
className="text-[#3B3B3B] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
className="text-[var(--ap-text)] text-[16px] font-normal text-center mt-[24px] sm:mt-[26px] md:mt-[29px] lg:mt-[32px] mb-[24px] md:mb-[16px]"
>
نوبت شما تأیید شد و پیامک تأیید برای بیمار ارسال میشود. برای نمایش نوبتها به قسمت نوبتهای من بروید.{" "}
</p>
@@ -26,7 +26,7 @@ function SuccessPay({ setStep }) {
variant="text"
className="!flex !items-center !justify-center !gap-[4px]"
>
<p className="text-[16px] font-medium text-[#5559CE]">
<p className="text-[16px] font-medium text-[var(--ap-primary)]">
برو به نوبت های من
</p>
<ArrowLeftBlueA />
@@ -55,6 +55,25 @@ function Card({ data, loading, setIsTurnsDetails }) {
</p>
</TextLoading>
</li>
{/* فیلدهای حالت نوبت‌دهی سرویسی. شرط اجباری است: نوبت اسلاتی این‌ها را ندارد و
بدون شرط، کارتِ همهٔ نوبت‌های اسلاتی می‌شکند — یعنی کل پنل کاربر. */}
{data.service_items?.length > 0 && (
<li className="flex items-start justify-between gap-[8px]">
<p className="text-[#7E7E7E] text-[14px] font-normal shrink-0">سرویس:</p>
<p className="text-[#525252] text-[14px] font-medium text-left">
{data.service_items.map((s) => s.name).join("، ")}
</p>
</li>
)}
{!data.is_reserve && data.service_total_minutes ? (
<li className="flex items-center justify-between">
<p className="text-[#7E7E7E] text-[14px] font-normal">مدت نوبت:</p>
<p className="text-[#525252] text-[14px] font-medium">
{data.service_total_minutes} دقیقه
</p>
</li>
) : null}
</ul>
<div className="mt-[12px] flex items-center justify-end px-[12px]">
<TextLoading loading={loading} width={80} height={25}>
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Card from "./Card";
// آیکن و لودینگ‌ها به این تست ربطی ندارند؛ فقط باید کودکشان را رندر کنند.
vi.mock("@/app/component/loading/Text", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("@/app/component/loading/Circular", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("@/components/icons/ArrowLeftCardD", () => ({ default: () => null }));
vi.mock("next/image", () => ({ default: () => null }));
const base = {
uuid: "ap1",
status: "confirmed",
slot_start: 1785567600,
doctor: { name: "دکتر تست" },
};
function renderCard(data) {
return render(<Card data={{ ...base, ...data }} loading={false} setIsTurnsDetails={() => {}} />);
}
describe("Card نوبت — فیلدهای حالت سرویسی", () => {
// ── ✅ موفق ──────────────────────────────────────────────────────────────
it("نام همهٔ سرویس‌ها و مدت نوبت را نشان می‌دهد", () => {
renderCard({
service_items: [{ uuid: "s1", name: "لیزر صورت" }, { uuid: "s2", name: "لیزر بیکینی" }],
service_total_minutes: 35,
});
expect(screen.getByText("سرویس:")).toBeInTheDocument();
expect(screen.getByText("لیزر صورت، لیزر بیکینی")).toBeInTheDocument();
expect(screen.getByText("مدت نوبت:")).toBeInTheDocument();
expect(screen.getByText("35 دقیقه")).toBeInTheDocument();
});
// ── ❌ خط سرخ: نوبت اسلاتی ────────────────────────────────────────────────
it("نوبت اسلاتی هیچ‌کدام از دو ردیف را نمی‌گیرد و کرش نمی‌کند", () => {
renderCard({ service_items: [], service_total_minutes: null });
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
// ردیف‌های موجود سر جایشان‌اند.
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
expect(screen.getByText("ساعت نوبت:")).toBeInTheDocument();
});
it("فیلدهای سرویسی کاملاً غایب (بک‌اند قدیمی) هم کرش نمی‌دهد", () => {
renderCard({});
expect(screen.queryByText("سرویس:")).not.toBeInTheDocument();
expect(screen.getByText("تاریخ نوبت:")).toBeInTheDocument();
});
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
it("نوبت رزرو سرویس را نشان می‌دهد ولی مدت را نه (زمان ندارد)", () => {
renderCard({
is_reserve: true,
service_items: [{ uuid: "s1", name: "لیزر" }],
service_total_minutes: 45,
});
expect(screen.getByText("لیزر")).toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
it("سرویس بدون مدت → فقط ردیف سرویس", () => {
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: null });
expect(screen.getByText("مشاوره")).toBeInTheDocument();
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
it("مدت صفر ردیف نمی‌سازد", () => {
renderCard({ service_items: [{ uuid: "s1", name: "مشاوره" }], service_total_minutes: 0 });
expect(screen.queryByText("مدت نوبت:")).not.toBeInTheDocument();
});
});
@@ -14,6 +14,9 @@ function Turns({ user, setIsTurnsDetails, loading }) {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedAppointment, setSelectedAppointment] = useState(null);
// تغییر عمدیِ کاربر (لغو یا جابه‌جایی) باید فهرست را تازه کند؛ وگرنه نوبتی که همین
// حالا لغو شد هنوز «تأییدشده» نشان داده می‌شود.
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const fetchAppointments = async () => {
@@ -51,7 +54,7 @@ function Turns({ user, setIsTurnsDetails, loading }) {
};
fetchAppointments();
}, [page, status]);
}, [page, status, reloadKey]);
const handlePageChange = (event, value) => {
setPage(value);
@@ -68,6 +71,10 @@ function Turns({ user, setIsTurnsDetails, loading }) {
<IsTurnsDetails
appointmentData={selectedAppointment}
setIsTurnsDetails={setSelectedAppointment}
onChanged={() => {
setSelectedAppointment(null);
setReloadKey((k) => k + 1);
}}
/>
) : (
<>
@@ -1,33 +1,39 @@
import { Button } from "@mui/material";
import ModalDeleteComment from "./modal";
import EditTurnsD from "@/components/icons/EditTurnsD";
import ArrowLeftD from "@/components/icons/ArrowLeftD";
import RescheduleModal from "./modal/RescheduleModal";
import DownloadD from "@/components/icons/DownloadD";
function ButtonData({ loading, onDownload }) {
/** نوبتی که گذشته یا از قبل لغو شده، نه لغو می‌شود نه جابه‌جا. */
function isOpen(appointment) {
if (!appointment?.uuid) return false;
if (String(appointment.status ?? "").startsWith("cancelled")) return false;
return (appointment.slot_start ?? 0) > Math.floor(Date.now() / 1000);
}
function ButtonData({ loading, onDownload, appointment, onChanged }) {
const actionable = isOpen(appointment);
return (
<div className="flex mt-[48px] md:mt-0 items-center justify-center md:justify-end gap-[16px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
{/* <Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
<ModalDeleteComment loading={loading} />
<Button
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="contained"
>
<EditTurnsD />
ویرایش نوبت
<div className="mr-[4px]">
<ArrowLeftD />
</div>
</Button> */}
{onDownload && (
<Button
className="!rounded-[4px] !border !border-solid !border-[#D7D7D7] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
variant="outlined"
onClick={onDownload}
>
<DownloadD />
دانلود اطلاعات نوبت
</Button>
)}
{actionable && (
<>
<ModalDeleteComment loading={loading} appointment={appointment} onDone={onChanged} />
<RescheduleModal loading={loading} appointment={appointment} onDone={onChanged} />
</>
)}
</div>
);
}
@@ -90,6 +90,29 @@ function DetailLg({ appointmentData, loading }) {
</div>
</TextLoading>
</li>
{/* حالت نوبت‌دهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
{appointmentData?.service_items?.length > 0 && (
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
<TextLoading loading={loading} width={200} height={20}>
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
سرویس:{" "}
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
{appointmentData.service_items.map((s) => s.name).join("، ")}
</span>
</p>
</TextLoading>
{!appointmentData.is_reserve && appointmentData.service_total_minutes ? (
<TextLoading loading={loading} width={110} height={20}>
<p className="text-[#616161] text-[14px] lg:text-[16px] font-normal">
مدت نوبت:{" "}
<span className="text-[#2F2F2F] text-[14px] lg:text-[16px] font-medium">
{appointmentData.service_total_minutes} دقیقه
</span>
</p>
</TextLoading>
) : null}
</li>
)}
<li className="flex flex-wrap items-center justify-start gap-y-[12px] gap-[32px]">
<TextLoading loading={loading} width={250} height={20}>
<div className="flex items-start justify-start gap-[4px]">
@@ -7,7 +7,7 @@ import { convertTimestampToJalali, convertTimestampToTime } from "@/helper";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
function DetailSm({ appointmentData, loading }) {
function DetailSm({ appointmentData, loading, onChanged }) {
const detailsRef = useRef(null);
const handleDownloadPDF = async () => {
@@ -85,6 +85,31 @@ function DetailSm({ appointmentData, loading }) {
</p>
</TextLoading>
</li>
{/* حالت نوبت‌دهی سرویسی. شرط اجباری: نوبت اسلاتی این فیلدها را ندارد. */}
{appointmentData?.service_items?.length > 0 && (
<li className="flex flex-wrap gap-x-[12px] items-start w-full justify-between mt-[12px]">
<TextLoading loading={loading} width={80} height={16}>
<p className="text-[#7E7E7E] text-[16px] font-normal shrink-0">سرویس:</p>
</TextLoading>
<TextLoading loading={loading} width={140} height={16}>
<p className="text-[#525252] text-[16px] font-medium text-left">
{appointmentData.service_items.map((s) => s.name).join("، ")}
</p>
</TextLoading>
</li>
)}
{!appointmentData?.is_reserve && appointmentData?.service_total_minutes ? (
<li className="flex flex-wrap items-center w-full justify-between mt-[12px]">
<TextLoading loading={loading} width={80} height={16}>
<p className="text-[#7E7E7E] text-[16px] font-normal">مدت نوبت:</p>
</TextLoading>
<TextLoading loading={loading} width={60} height={16}>
<p className="text-[#525252] text-[16px] font-medium">
{appointmentData.service_total_minutes} دقیقه
</p>
</TextLoading>
</li>
) : null}
<span className="block w-full h-px bg-[#EFEFEF] mt-[12px] mb-[16px]"></span>
<li className="flex flex-wrap gap-x-[12px] gap-y-[12px] items-center w-full justify-between">
<TextLoading loading={loading} width={200} height={16}>
@@ -116,7 +141,12 @@ function DetailSm({ appointmentData, loading }) {
</p>
</TextLoading>
</ul>
<ButtonData loading={loading} onDownload={handleDownloadPDF} />
<ButtonData
loading={loading}
onDownload={handleDownloadPDF}
appointment={appointmentData}
onChanged={onChanged}
/>
</div>
);
}
@@ -3,7 +3,7 @@ import { Button } from "@mui/material";
import ButtonData from "./ButtonData";
import TextLoading from "@/app/component/loading/Text";
function Head({ setIsTurnsDetails }) {
function Head({ setIsTurnsDetails, appointment, onChanged }) {
return (
<div className="hidden md:flex flex-wrap gap-x-[24px] gap-y-[12px] items-center justify-between">
<div className="flex items-center justify-start gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px]">
@@ -19,7 +19,7 @@ function Head({ setIsTurnsDetails }) {
</p>
</TextLoading>
</div>
<ButtonData loading={false} />
<ButtonData loading={false} appointment={appointment} onChanged={onChanged} />
</div>
);
}
@@ -2,12 +2,16 @@ import DetailLg from "./DetailLg";
import DetailSm from "./DetailSm";
import Head from "./Head";
function IsTurnsDetails({ appointmentData, setIsTurnsDetails }) {
function IsTurnsDetails({ appointmentData, setIsTurnsDetails, onChanged }) {
return (
<div className="md:py-[20px] md:px-[24px] opacity-page">
<Head setIsTurnsDetails={setIsTurnsDetails} />
<DetailLg appointmentData={appointmentData} loading={false} />
<DetailSm appointmentData={appointmentData} loading={false} />
<Head
setIsTurnsDetails={setIsTurnsDetails}
appointment={appointmentData}
onChanged={onChanged}
/>
<DetailLg appointmentData={appointmentData} loading={false} onChanged={onChanged} />
<DetailSm appointmentData={appointmentData} loading={false} onChanged={onChanged} />
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useState } from "react";
import { Box, Button, Modal } from "@mui/material";
import moment from "moment-jalaali";
import { styleDefault } from "@/mui";
import CloseModalD from "@/components/icons/CloseModalD";
import { request } from "@/services/response";
import { adaptServiceSlots } from "@/lib/appointmentSlots";
const DAYS_AHEAD = 14;
/** چهارده روز آینده — نوبتی که بیمار خودش جابه‌جا می‌کند، ماه‌ها جلوتر نمی‌رود. */
function nextDays() {
return Array.from({ length: DAYS_AHEAD }, (_, i) => {
const day = moment().add(i, "day");
return {
value: day.format("YYYY-MM-DD"),
label: day.format("jD jMMMM"),
weekday: day.format("dddd"),
};
});
}
/**
* جابه‌جایی نوبت از پنل کاربر.
*
* مدت را **سرور** حساب می‌کند: بیمار فقط زمان شروع می‌فرستد. اگر فرانت مدت را می‌فرستاد،
* دو محاسبهٔ موازی داشتیم و روزی که تعرفه یا مدت سرویس عوض می‌شد، نوبت با مدت کهنه
* جابه‌جا می‌شد.
*
* وقت‌ها با `exclude_appointment_uuid` گرفته می‌شوند تا خودِ نوبت فعلی جای خالی حساب
* شود؛ وگرنه بیمار ساعت خودش را «پر» می‌بیند.
*/
function RescheduleModal({ appointment, loading, onDone }) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(null);
const [slots, setSlots] = useState([]);
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const days = nextDays();
const doctorUuid = appointment?.doctor?.uuid;
useEffect(() => {
if (!open || !date || !doctorUuid || !appointment?.uuid) return;
let cancelled = false;
setError(null);
setPicked(null);
setBusy(true);
request
.getServiceSlotsForReschedule(
doctorUuid,
date,
appointment.uuid,
(appointment.service_items ?? []).map((s) => s.uuid).filter(Boolean)
)
.then((res) => {
if (cancelled) return;
const groups = adaptServiceSlots(res);
setSlots(groups.flatMap((g) => g.slots ?? []));
})
.catch((e) => {
if (!cancelled) setError(e?.message || "گرفتن وقت‌های آزاد ناموفق بود");
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [open, date, doctorUuid, appointment?.uuid]);
const close = () => {
setOpen(false);
setDate(null);
setSlots([]);
setPicked(null);
setError(null);
};
const submit = async () => {
if (!picked) return;
setBusy(true);
setError(null);
try {
await request.serviceReschedule(appointment.uuid, { start: picked.start });
close();
onDone?.();
} catch (e) {
// ۴۰۹ یعنی همین لحظه کس دیگری همان وقت را گرفت — پیام سرور دقیقاً همین را می‌گوید
// و فهرست دوباره خوانده می‌شود تا بیمار جایگزین ببیند.
setError(e?.message || "جابه‌جایی نوبت ناموفق بود");
setDate((d) => d);
setBusy(false);
}
};
return (
<div>
<Button
className="!rounded-[4px] !shadow-none !gap-[4px] !py-[6px] md:!py-[7px] lg:!py-[8px] !px-[12px]"
disabled={loading}
onClick={() => setOpen(true)}
variant="contained"
>
جابهجایی نوبت
</Button>
<Modal open={open} onClose={close}>
<Box
sx={styleDefault}
className="!w-fit !min-w-[328px] md:!w-fit md:!min-w-[552px] !py-[20px] !px-[24px]"
>
<div className="flex items-center justify-between w-full">
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
جابهجایی نوبت
</p>
<Button onClick={close} className="!p-1" variant="text">
<CloseModalD />
</Button>
</div>
<span className="block h-px w-full bg-[#EFEFEF] my-[12px] md:my-[14px] lg:my-[16px]" />
<p className="text-[#525252] text-[12px] md:text-[14px] font-normal">
روز و ساعت تازه را انتخاب کنید. سرویسهای نوبت و مدت آن تغییری نمیکنند.
</p>
<div className="mt-[14px] flex gap-[8px] overflow-x-auto pb-2">
{days.map((day) => (
<button
key={day.value}
type="button"
onClick={() => setDate(day.value)}
className={`shrink-0 rounded-[6px] border px-[12px] py-[6px] text-[12px] ${
date === day.value
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
: "border-[#EFEFEF] text-[#616161]"
}`}
>
<span className="block">{day.label}</span>
<span className="block text-[10px] text-[#9A9A9A]">{day.weekday}</span>
</button>
))}
</div>
{busy && (
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">در حال بررسی وقتهای آزاد</p>
)}
{!busy && date && slots.length === 0 && !error && (
<p className="mt-[12px] text-[12px] text-[#7E7E7E]">
در این روز وقت آزادی نیست؛ روز دیگری را امتحان کنید.
</p>
)}
{slots.length > 0 && (
<div className="mt-[12px] flex flex-wrap gap-[8px]">
{slots.map((slot) => (
<button
key={slot.start}
type="button"
onClick={() => setPicked(slot)}
className={`rounded-[6px] border px-[12px] py-[6px] text-[13px] ${
picked?.start_time === slot.start_time
? "border-[#5559CE] bg-[#5559CE]/10 text-[#5559CE]"
: "border-[#EFEFEF] text-[#616161]"
}`}
>
{slot.start_time}
</button>
))}
</div>
)}
{error && <p className="mt-[12px] text-[12px] text-[#D64545]">{error}</p>}
<div className="flex items-center justify-end gap-[16px] mt-[16px]">
<Button
variant="outlined"
onClick={close}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !border-[#828DE0] !text-[#828DE0] !text-[16px] !font-medium"
>
انصراف
</Button>
<Button
variant="contained"
disabled={!picked || busy}
onClick={submit}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
>
جابهجا کن
</Button>
</div>
</Box>
</Modal>
</div>
);
}
export default RescheduleModal;
@@ -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(<RescheduleModal appointment={appointment} />);
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(<RescheduleModal appointment={appointment} onDone={onDone} />);
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();
});
});
@@ -1,12 +1,66 @@
import { useState } from "react";
"use client";
import { useEffect, useState } from "react";
import { Box, Button, Modal } from "@mui/material";
import { styleDefault } from "@/mui";
import CloseModalD from "@/components/icons/CloseModalD";
import { request } from "@/services/response";
import { numberToArStyle } from "@/helper";
function ModalDeleteComment({ loading }) {
/**
* لغو نوبت با نمایش پیامد مالی **پیش از** تأیید.
*
* پیش‌نمایش از همان محاسبه‌ای می‌آید که خودِ لغو انجام می‌دهد، پس عددی که بیمار می‌بیند
* همان است که کسر می‌شود. تا پیش از این، این مودال هیچ درخواستی نمی‌فرستاد و دکمهٔ
* «لغو نوبت» فقط پنجره را می‌بست — یعنی متن تبلیغاتی سایت وعده‌ای می‌داد که UI نداشت.
*/
function ModalDeleteComment({ loading, appointment, onDone }) {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const handleClose = () => {
setOpen(false);
setPreview(null);
setError(null);
};
useEffect(() => {
if (!open || !appointment?.uuid) return;
let cancelled = false;
request
.getCancellationPreview(appointment.uuid)
.then((res) => {
if (!cancelled) setPreview(res?.data ?? res ?? null);
})
// نبودِ پیش‌نمایش نباید لغو را قفل کند؛ فقط باید صریح گفته شود.
.catch(() => {
if (!cancelled) setPreview(null);
});
return () => {
cancelled = true;
};
}, [open, appointment?.uuid]);
const submit = async () => {
setBusy(true);
setError(null);
try {
await request.cancelAppointment(appointment.uuid, { by: "user" });
handleClose();
onDone?.();
} catch (e) {
setError(e?.message || "لغو نوبت ناموفق بود");
} finally {
setBusy(false);
}
};
return (
<div>
@@ -37,6 +91,38 @@ function ModalDeleteComment({ loading }) {
<p className="text-[#525252] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
آیا از لغو کردن نوبت مطمئن هستید؟
</p>
<div className="mt-[12px] rounded-[6px] bg-[#F7F7F7] p-[12px] text-[12px] md:text-[13px] leading-[2]">
{preview === null ? (
<span className="text-[#7E7E7E]">
پیامد مالی لغو در دسترس نیست؛ لغو انجام میشود ولی مبلغ را از پشتیبانی
بپرسید.
</span>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-[#7E7E7E]">جریمهٔ لغو</span>
<strong className={preview.penalty_rials > 0 ? "text-[#D64545]" : "text-[#2E9E63]"}>
{preview.penalty_rials > 0
? `${numberToArStyle(Math.round(preview.penalty_rials / 10))} تومان`
: "بدون جریمه"}
</strong>
</div>
{preview.within_free_window && (
<div className="text-[#2E9E63]">این لغو در بازهٔ رایگان است.</div>
)}
{(preview.notes ?? []).map((note, i) => (
<div key={i} className="text-[#9A9A9A]">
{note}
</div>
))}
</>
)}
</div>
{error && <p className="mt-[10px] text-[12px] text-[#D64545]">{error}</p>}
<div className="flex items-center justify-end gap-[16px] mt-[12px]">
<Button
variant="outlined"
@@ -47,7 +133,8 @@ function ModalDeleteComment({ loading }) {
</Button>
<Button
variant="contained"
onClick={handleClose}
disabled={busy}
onClick={submit}
className="!py-[4px] md:!py-[6px] lg:!py-[8px] !px-[8px] md:!px-[12px] lg:!px-[16px] !rounded-[4px] !text-[#EFEFEF] !text-[16px] !font-medium"
>
لغو نوبت
@@ -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(<ModalDeleteComment appointment={appointment} />);
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(<ModalDeleteComment appointment={appointment} />);
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(<ModalDeleteComment appointment={appointment} onDone={onDone} />);
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(<ModalDeleteComment appointment={appointment} />);
await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' }));
expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled();
});
});
+35 -7
View File
@@ -14,22 +14,50 @@ export function adaptSlots(slotsResponse) {
}));
}
// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی).
// آن‌ها را در یک session قالب‌بندی می‌کنیم تا مثل حالت اسلاتی رندر شوند.
/**
* حالت سرویسی: پاسخ `appointment-service-slots` فقط `start_times` مسطح می‌دهد و مرزِ
* شیفت‌ها را نمی‌گوید. یک session ساخته می‌شود تا مثل حالت اسلاتی رندر شود.
*
* ⚠️ **چرا شیفت‌ها تفکیک نمی‌شوند:** با فهرست مسطح، شکافِ بین دو شیفت از شکافِ یک نوبتِ
* اشغال‌شده قابل تفکیک نیست. گام عادی `مدت + بافر` است، و دو نوبت پشت‌سرهم شکافی
* می‌سازد که از تعطیلیِ میانِ صبح و عصر تشخیص‌پذیر نیست. هر آستانه‌ای که این دو را
* جدا کند، روی سرویس‌های بلند (گام > آستانه) هر اسلات را یک تب می‌کند و روی نوبت‌های
* اشغال، تبِ جعلی می‌سازد — یعنی اطلاعات غلط، که از یک تبِ درست بدتر است.
*
* راه درست، گروه‌بندی از سمت سرور است (endpoint چندمنبعی، تسک ۰۶). تا آن زمان یک
* session با **بازهٔ واقعی** برگردانده می‌شود.
*/
export function adaptServiceSlots(slotsResponse) {
const starts =
slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? [];
const payload = slotsResponse?.data ?? slotsResponse ?? {};
const starts = payload.start_times ?? [];
if (!starts.length) return [];
const first = starts[0];
const last = starts[starts.length - 1];
// پایانِ آخرین نوبت، نه زمانِ **شروعِ** آن — قبلاً `start_time` گذاشته می‌شد و برچسب
// بازه را کوتاه‌تر از واقعیت نشان می‌داد.
const endTime = last.end_time ?? addMinutes(last.start_time, payload.total_duration_minutes);
return [
{
start_time: starts[0].start_time,
end_time: starts[starts.length - 1].start_time,
label: "زمان‌های خالی",
start_time: first.start_time,
end_time: endTime,
label: `${first.start_time} - ${endTime}`,
slots: starts.map((s) => ({ ...s, is_available: true })),
},
];
}
/** `"09:00" + 35` → `"09:35"`. فقط fallback؛ پاسخ سرور `end_time` دارد. */
function addMinutes(time, minutes) {
const [h, m] = String(time).split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(minutes)) return time;
const total = h * 60 + m + minutes;
const hh = String(Math.floor(total / 60) % 24).padStart(2, "0");
const mm = String(total % 60).padStart(2, "0");
return `${hh}:${mm}`;
}
export function hasAvailable(slots) {
return Array.isArray(slots) && slots.some((slot) => slot.is_available);
}
+6
View File
@@ -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(() => {
+91 -16
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { adaptSlots, hasAvailable } from '@/lib/appointmentSlots';
import { adaptSlots, adaptServiceSlots, hasAvailable } from '@/lib/appointmentSlots';
import { getAccessToken, setAccessToken, clearAccessToken } from '@/lib/tokenStore';
import { sanitizeHtml, safeJsonParse } from '@/lib/sanitize';
import { defineAbilitiesFor } from '@/lib/ability';
@@ -7,28 +7,48 @@ import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from '@/lib/refresh
import { buildPatientUser } from '@/lib/representationAdapters';
describe('appointmentSlots', () => {
// مرزِ شیفت را بک‌اند می‌دهد؛ فرانت تقسیم صبح/عصر نمی‌سازد. سه تست قبلی روی
// قرارداد قدیمیِ `{ morning, evening }` نوشته شده بودند و از زمانی که خروجی به
// آرایهٔ session تغییر کرد قرمز مانده بودند.
const resp = {
sessions: [
{ slots: [
{ start_time: '09:00', is_available: true },
{ start_time: '11:30', is_available: false },
{ start_time: '14:00', is_available: true },
] },
{
start_time: '09:00',
end_time: '12:00',
slots: [
{ start_time: '09:00', is_available: true },
{ start_time: '11:30', is_available: false },
],
},
{
start_time: '16:00',
end_time: '20:00',
slots: [{ start_time: '16:00', is_available: true }],
},
],
};
it('adaptSlots اسلات‌ها را بر اساس 12:00 به صبح/عصر تقسیم می‌کند', () => {
const { morning, evening } = adaptSlots(resp);
expect(morning).toHaveLength(2);
expect(evening).toHaveLength(1);
expect(evening[0].start_time).toBe('14:00');
it('adaptSlots هر شیفتِ بک‌اند را یک session با برچسب بازه می‌کند', () => {
const sessions = adaptSlots(resp);
expect(sessions).toHaveLength(2);
expect(sessions[0].label).toBe('09:00 - 12:00');
expect(sessions[1].label).toBe('16:00 - 20:00');
expect(sessions[0].slots).toHaveLength(2);
});
it('ساختار data.sessions را هم می‌پذیرد', () => {
const { morning } = adaptSlots({ data: resp });
expect(morning).toHaveLength(2);
it('adaptSlots ساختار data.sessions را هم می‌پذیرد', () => {
expect(adaptSlots({ data: resp })).toHaveLength(2);
});
it('ورودی خالی → آرایه‌های خالی', () => {
expect(adaptSlots(null)).toEqual({ morning: [], evening: [] });
it('adaptSlots شیفتِ بدون اسلات را حذف می‌کند', () => {
const withEmpty = { sessions: [...resp.sessions, { start_time: '21:00', end_time: '22:00', slots: [] }] };
expect(adaptSlots(withEmpty)).toHaveLength(2);
});
it('adaptSlots ورودی خالی → آرایهٔ خالی', () => {
expect(adaptSlots(null)).toEqual([]);
});
it('hasAvailable', () => {
expect(hasAvailable([{ is_available: false }, { is_available: true }])).toBe(true);
expect(hasAvailable([{ is_available: false }])).toBe(false);
@@ -37,6 +57,61 @@ describe('appointmentSlots', () => {
});
});
describe('adaptServiceSlots', () => {
// پاسخ واقعی: شیفت ۰۹:۰۰–۱۲:۰۰، دو سرویس ۲۰+۱۵، بافر ۱۰ ⇒ گام ۴۵ دقیقه.
const serviceResp = {
data: {
total_duration_minutes: 35,
buffer_minutes: 10,
start_times: [
{ start: 1785562200, end: 1785564300, start_time: '09:00', end_time: '09:35' },
{ start: 1785564900, end: 1785567000, start_time: '09:45', end_time: '10:20' },
{ start: 1785567600, end: 1785569700, start_time: '10:30', end_time: '11:05' },
{ start: 1785570300, end: 1785572400, start_time: '11:15', end_time: '11:50' },
],
},
};
it('یک session با بازهٔ واقعی می‌سازد', () => {
const [session] = adaptServiceSlots(serviceResp);
expect(session.start_time).toBe('09:00');
// پایانِ آخرین نوبت، نه زمانِ شروعش.
expect(session.end_time).toBe('11:50');
expect(session.label).toBe('09:00 - 11:50');
expect(session.slots).toHaveLength(4);
});
it('همهٔ زمان‌های پیشنهادی available علامت می‌خورند', () => {
const [session] = adaptServiceSlots(serviceResp);
expect(session.slots.every((s) => s.is_available)).toBe(true);
expect(hasAvailable(session.slots)).toBe(true);
});
it('ساختار بدون data را هم می‌پذیرد', () => {
expect(adaptServiceSlots(serviceResp.data)).toHaveLength(1);
});
it('نبودِ end_time در پاسخ → از total_duration_minutes ساخته می‌شود', () => {
const withoutEnd = {
data: {
total_duration_minutes: 35,
start_times: [{ start_time: '09:00' }],
},
};
expect(adaptServiceSlots(withoutEnd)[0].end_time).toBe('09:35');
});
it('start_times خالی → آرایهٔ خالی', () => {
expect(adaptServiceSlots({ data: { start_times: [] } })).toEqual([]);
expect(adaptServiceSlots(null)).toEqual([]);
});
it('یک اسلات تنها → بازهٔ همان اسلات', () => {
const single = { data: { start_times: [{ start_time: '15:00', end_time: '15:30' }] } };
expect(adaptServiceSlots(single)[0].label).toBe('15:00 - 15:30');
});
});
describe('tokenStore', () => {
beforeEach(() => clearAccessToken());
it('set/get/clear', () => {
+39
View File
@@ -88,6 +88,45 @@ export const request = {
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
/**
* وقت‌های آزاد برای **جابه‌جایی** یک نوبت.
*
* `exclude_appointment_uuid` باعث می‌شود خودِ نوبت فعلی جای خالی حساب شود؛ بدون آن،
* بیمار ساعت خودش را «پر» می‌بیند و نمی‌تواند مثلاً فقط سرویس‌هایش را عوض کند.
* سرور مالکیت آن uuid را می‌سنجد، پس این پارامتر ظرفیت ساختگی نمی‌سازد.
*/
getServiceSlotsForReschedule: (
doctor_uuid,
date,
appointment_uuid,
serviceItemUuids = [],
clinic_uuid = null
) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
`&exclude_appointment_uuid=${encodeURIComponent(appointment_uuid)}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join("") +
clinicQuery(clinic_uuid),
{ requireAuth: true }
),
/** مدت را سرور حساب می‌کند؛ بیمار فقط زمان شروع (و در صورت تغییر، سرویس‌ها) می‌فرستد. */
serviceReschedule: (appointment_uuid, data) =>
api.post(`api/v1/appointment/${appointment_uuid}/service-reschedule`, data, {
requireAuth: true,
}),
/** پیامد مالی لغو، پیش از تأیید — همان محاسبه‌ای که خودِ لغو انجام می‌دهد. */
getCancellationPreview: (appointment_uuid) =>
api.get(`api/v1/appointment/${appointment_uuid}/cancellation-preview`, {
requireAuth: true,
}),
cancelAppointment: (appointment_uuid, data = {}) =>
api.post(`api/v1/appointment/${appointment_uuid}/cancel`, data, { requireAuth: true }),
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
+13 -1
View File
@@ -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"] *)'],
],
};