From 56e264e44c8dd36d5afd0bc71ef5e2c139e2c07d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 17:19:22 +0330 Subject: [PATCH] feat: Refactor specialty display logic and enhance specialty filtering - Introduced SpecialtyChips component to manage the display of doctor's specialties with a primary specialty and a count of additional specialties. - Updated ItemDoctor component to utilize SpecialtyChips for better UI presentation. - Enhanced PosterLight and Poster components to display primary specialties and a text line for sub-specialties. - Implemented nextSpecialtyFilter function to improve specialty selection logic in the Content component. - Updated search functionality to allow searching by both doctor name and specialty. - Added new specialties to specialties.json for better coverage. - Created sync-specialties script to synchronize specialties data with the backend during build. - Added tests for new components and helper functions to ensure functionality and reliability. --- app/component/ItemDoctor.js | 9 +- app/component/SpecialtyChips.js | 81 +++++++++++ app/component/SpecialtyChips.test.js | 74 ++++++++++ components/doctor/poster/PosterLight.js | 28 ++-- components/doctor/poster/index.js | 31 +++-- components/doctor/poster/poster.test.js | 84 ++++++++++++ components/doctors/modal/Content.js | 15 +-- components/doctors/modal/form/index.js | 8 +- components/doctors/search/SearchField.js | 2 +- data/specialties.json | 47 ++++++- helper/index.js | 49 +++++-- helper/specialtyFilter.test.js | 165 +++++++++++++++++++++++ lib/specialtyDisplay.js | 58 ++++++++ lib/specialtyDisplay.test.js | 119 ++++++++++++++++ package.json | 1 + scripts/sync-specialties.mjs | 80 +++++++++++ 16 files changed, 799 insertions(+), 52 deletions(-) create mode 100644 app/component/SpecialtyChips.js create mode 100644 app/component/SpecialtyChips.test.js create mode 100644 components/doctor/poster/poster.test.js create mode 100644 helper/specialtyFilter.test.js create mode 100644 lib/specialtyDisplay.js create mode 100644 lib/specialtyDisplay.test.js create mode 100644 scripts/sync-specialties.mjs diff --git a/app/component/ItemDoctor.js b/app/component/ItemDoctor.js index 92932e8..cf26e41 100644 --- a/app/component/ItemDoctor.js +++ b/app/component/ItemDoctor.js @@ -5,6 +5,7 @@ import CustomLoading from "./loading/Custom"; import TextLoading from "./loading/Text"; import CircularLoading from "./loading/Circular"; import DoctorAvatar from "./DoctorAvatar"; +import SpecialtyChips from "./SpecialtyChips"; // Icons import ArrowLeftD from "@/components/icons/ArrowLeftD"; @@ -44,13 +45,7 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) { -

- تخصص: - {doctor?.specialties?.map( - (item, idx) => - `${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} ` - )} -

+
diff --git a/app/component/SpecialtyChips.js b/app/component/SpecialtyChips.js new file mode 100644 index 0000000..9b5befd --- /dev/null +++ b/app/component/SpecialtyChips.js @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Popover } from "@mui/material"; +import { splitSpecialties } from "@/lib/specialtyDisplay"; + +/** + * تخصص اصلی + شمارندهٔ بقیه در کارت پزشک. + * + * پیش از این همهٔ نام‌ها پشت‌سرهم چاپ می‌شدند و کارتِ پزشکِ شش‌تخصصی در موبایل + * چند برابر بقیه بلند می‌شد و شبکه را به‌هم می‌ریخت. + * + * جدا از ItemDoctor است تا مرز client فقط همین‌جا باشد؛ کارت از کامپوننت‌های + * سروری هم رندر می‌شود. + */ +function SpecialtyChips({ specialties }) { + const [anchorEl, setAnchorEl] = useState(null); + const { primary, rest } = splitSpecialties(specialties); + + if (!primary) return null; + + const open = Boolean(anchorEl); + + // کارت داخل است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز می‌کند. + const handleOpen = (event) => { + event.preventDefault(); + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }; + + const handleClose = (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + setAnchorEl(null); + }; + + return ( + + + تخصص: {primary.name} + + + {rest.length > 0 && ( + <> + + + +
    + {[primary, ...rest].map((item) => ( +
  • + {item.name} +
  • + ))} +
+
+ + )} +
+ ); +} + +export default SpecialtyChips; diff --git a/app/component/SpecialtyChips.test.js b/app/component/SpecialtyChips.test.js new file mode 100644 index 0000000..47c4c09 --- /dev/null +++ b/app/component/SpecialtyChips.test.js @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import Link from "next/link"; +import SpecialtyChips from "@/app/component/SpecialtyChips"; + +const root = { id: "13", name: "جراحی عمومی", parent_id: null }; +const gi = { id: "169", name: "جراح گوارش", parent_id: "13" }; +const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" }; + +describe("SpecialtyChips", () => { + it("تخصص اصلی را نشان می‌دهد و بقیه را می‌شمارد", () => { + render(); + + expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2"); + + // بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه می‌دارد. + expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument(); + }); + + it("کلیک روی شمارنده فهرست کامل را باز می‌کند", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })); + + expect(screen.getByText("جراح گوارش")).toBeInTheDocument(); + expect(screen.getByText("جراح تیروئید")).toBeInTheDocument(); + // تخصص اصلی هم در فهرست هست تا تصویر کامل باشد. + expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1); + }); + + it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => { + // کارت داخل است؛ بدون preventDefault/stopPropagation هر بار که کاربر + // تخصص‌ها را می‌بیند به صفحهٔ پزشک پرت می‌شود. + const onParentClick = vi.fn(); + + render( + + + + ); + + // fireEvent مقدار false می‌دهد وقتی preventDefault صدا زده شده باشد. + const notPrevented = fireEvent.click( + screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" }) + ); + + expect(notPrevented).toBe(false); + expect(onParentClick).not.toHaveBeenCalled(); + }); + + it("تک‌تخصص هیچ شمارنده‌ای ندارد", () => { + render(); + + expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("پزشک بدون تخصص چیزی رندر نمی‌کند و کرش نمی‌کند", () => { + const { container: empty } = render(); + expect(empty).toBeEmptyDOMElement(); + + const { container: missing } = render(); + expect(missing).toBeEmptyDOMElement(); + }); + + it("شمارنده متن دارد نه فقط علامت — برای صفحه‌خوان", () => { + render(); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("type", "button"); + expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر"); + }); +}); diff --git a/components/doctor/poster/PosterLight.js b/components/doctor/poster/PosterLight.js index 37f3d9d..bdcd6c8 100644 --- a/components/doctor/poster/PosterLight.js +++ b/components/doctor/poster/PosterLight.js @@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar"; import QrImg from "./QrImg"; import SiteLogo from "./SiteLogo"; import { getStateInfoClient } from "@/lib/getStateInfoClient"; +import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay"; import { doctorTitle } from "@/helper"; import StarDI from "@/components/icons/StarDI"; @@ -21,7 +22,9 @@ function PosterLight({ data, onQrReady }) { setCity(matchedCity || null); }, []); - const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4); + // قرینهٔ پوستر تیره — همان دلیل: برش عددیِ ثابت با نام‌های بلند از کادر بیرون می‌زد. + const { primary, rest } = splitSpecialties(data?.specialties); + const subSpecialties = posterSpecialtyLine(rest, 78); const services = (data?.expertise ?? []).filter((s) => s?.name); const shownServices = services.slice(0, 8); const moreServices = services.length - shownServices.length; @@ -88,18 +91,23 @@ function PosterLight({ data, onQrReady }) { {doctorTitle(data?.name)} -
- {specialties.map((s, i) => ( - + {primary && ( +
+ - {s.name} + {primary.name} - ))} -
+
+ )} + + {/* زیرتخصص‌ها یک خط متنی — در چاپ نه کلیک هست نه Popover. */} + {subSpecialties.text && ( +

+ {subSpecialties.text} + {subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`} +

+ )} {badges.length > 0 && (
diff --git a/components/doctor/poster/index.js b/components/doctor/poster/index.js index 592f72f..99ce4a7 100644 --- a/components/doctor/poster/index.js +++ b/components/doctor/poster/index.js @@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar"; import QrImg from "./QrImg"; import SiteLogoBase from "./SiteLogo"; import { getStateInfoClient } from "@/lib/getStateInfoClient"; +import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay"; import { doctorTitle } from "@/helper"; import StarDI from "@/components/icons/StarDI"; @@ -41,7 +42,10 @@ function Poster({ data, onQrReady }) { setCity(matchedCity || null); }, []); - const specialties = (data?.specialties ?? []).filter((s) => s?.name).slice(0, 4); + // برش بر اساس طول متن است نه عدد ثابت: چهار چیپ با نام‌های بلند به سه ردیف + // می‌رفت، بخش hero را بلند می‌کرد و بخش‌های پایین را از کادر ثابت بیرون می‌انداخت. + const { primary, rest } = splitSpecialties(data?.specialties); + const subSpecialties = posterSpecialtyLine(rest, 78); const services = (data?.expertise ?? []).filter((s) => s?.name); const shownServices = services.slice(0, 8); const moreServices = services.length - shownServices.length; @@ -128,18 +132,25 @@ function Poster({ data, onQrReady }) {

{doctorTitle(data?.name)}

-
- {specialties.map((s, i) => ( - + {primary && ( +
+ - {s.name} + {primary.name} - ))} -
+
+ )} + + {/* زیرتخصص‌ها یک خط متنی‌اند نه چیپ: در چاپ نه کلیک هست نه Popover، پس + «+N» بی‌معناست و متن خوانا جایش را می‌گیرد. max-h تور ایمنیِ کادر است + اگر بودجهٔ کاراکتر روزی کوتاه تنظیم شود. */} + {subSpecialties.text && ( +

+ {subSpecialties.text} + {subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`} +

+ )}
{badges.map((b, i) => ( ({ + getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }), +})); + +vi.mock("./QrImg", () => ({ default: () =>
})); +vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () =>
})); + +// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که API برمی‌گرداند. +const JAHANTAB = { + name: "محمدباقر جهانتاب", + specialties: [ + { id: "13", name: "جراحی عمومی", parent_id: null }, + { id: "14", name: "جراحی پلاستیک و زیبایی", parent_id: "13" }, + { id: "167", name: "جراحی لاپاراسکوپی", parent_id: "13" }, + { id: "168", name: "جراح تیروئید", parent_id: "13" }, + { id: "169", name: "جراح گوارش", parent_id: "13" }, + { id: "170", name: "جراحی سرطانها", parent_id: "13" }, + ], + expertise: [], + address: [], +}; + +describe.each([ + ["پوستر تیره", Poster], + ["پوستر روشن", PosterLight], +])("%s", (_label, Component) => { + it("تخصص والد را به‌عنوان تیتر نشان می‌دهد", () => { + render(); + + expect(screen.getByText("جراحی عمومی")).toBeInTheDocument(); + }); + + it("زیرتخصص‌ها یک خط متنی‌اند، نه چیپ جداگانه", () => { + render(); + + // اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت. + expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument(); + expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument(); + }); + + it("مازاد را می‌شمارد و نمی‌برد", () => { + render(); + + expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument(); + }); + + it("کادر ثابت ۱۰۸۰×۱۳۵۰ و overflow-hidden دست‌نخورده است", () => { + const { container } = render(); + const frame = container.firstChild; + + expect(frame.className).toContain("w-[1080px]"); + expect(frame.className).toContain("h-[1350px]"); + expect(frame.className).toContain("overflow-hidden"); + }); + + it("خط زیرتخصص از بودجهٔ کاراکتر نمی‌گذرد", () => { + // jsdom چیدمان را اندازه نمی‌گیرد، پس سرریزِ پیکسلی اینجا اثبات‌شدنی نیست. + // چیزی که اثبات می‌شود همان سازوکاری است که سرریز را می‌بندد: طول متن کراندار. + render(); + + const line = screen.getByText(/جراح گوارش/); + expect(line.textContent.length).toBeLessThan(120); + }); + + it("پزشک تک‌تخصص خط زیرتخصص ندارد", () => { + render( + + ); + + expect(screen.getByText("جراحی عمومی")).toBeInTheDocument(); + expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument(); + }); + + it("پزشک بدون تخصص کرش نمی‌کند", () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/components/doctors/modal/Content.js b/components/doctors/modal/Content.js index 593ac24..cca1b59 100644 --- a/components/doctors/modal/Content.js +++ b/components/doctors/modal/Content.js @@ -1,20 +1,9 @@ -import specialties from "@/data/specialties.json"; +import { nextSpecialtyFilter } from "@/helper"; import Form from "./form"; function Content({ filter, setFilter, updateData, setDataInURL }) { const changeSpecialty = (name, value) => { - const newFilter = { ...filter, [name]: value }; - - if (name === "category") { - if (value) { - const filtered = specialties - .filter((item) => item.parent_id) - .filter((item) => String(item.parent_id) === String(value.id)); - newFilter.specialty = filtered[0] || null; - } else { - newFilter.specialty = null; - } - } + const newFilter = nextSpecialtyFilter(filter, name, value); setFilter(newFilter); setDataInURL(newFilter); diff --git a/components/doctors/modal/form/index.js b/components/doctors/modal/form/index.js index 1642968..6280f54 100644 --- a/components/doctors/modal/form/index.js +++ b/components/doctors/modal/form/index.js @@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
diff --git a/components/doctors/search/SearchField.js b/components/doctors/search/SearchField.js index 8dd3569..406c53a 100644 --- a/components/doctors/search/SearchField.js +++ b/components/doctors/search/SearchField.js @@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) { clearText={clearText} inputValue={filter.name} handleSearch={handleSearch} - placeholder="جستجوی نام پزشک ..." + placeholder="جستجوی نام پزشک یا تخصص ..." />
); diff --git a/data/specialties.json b/data/specialties.json index ba463aa..8e9ca07 100644 --- a/data/specialties.json +++ b/data/specialties.json @@ -844,5 +844,50 @@ "status": 1, "weight": 0, "parent_id": 19 + }, + { + "id": 167, + "uuid": "86b4cebc-ca17-4c4e-9b1e-c1310ab0521f", + "name": "جراحی لاپاراسکوپی", + "slug": "جراحی-لاپاراسکوپی", + "status": 1, + "weight": 0, + "parent_id": 13 + }, + { + "id": 168, + "uuid": "6828d049-f0b3-421e-930a-4b6ac57c75f1", + "name": "جراح تیروئید", + "slug": "جراح-تیروئید", + "status": 1, + "weight": 0, + "parent_id": 13 + }, + { + "id": 169, + "uuid": "fbce44e2-d9b6-412e-a64a-bca4d27a6969", + "name": "جراح گوارش", + "slug": "جراح-گوارش", + "status": 1, + "weight": 0, + "parent_id": 13 + }, + { + "id": 170, + "uuid": "94fe785b-4886-4710-9619-a7d52050ed40", + "name": "جراحی سرطانها", + "slug": "جراحی-سرطانها", + "status": 1, + "weight": 0, + "parent_id": 13 + }, + { + "id": 171, + "uuid": "57a5cc39-602e-4e0a-80df-ffd073d8c3d2", + "name": "جراحی جنرال", + "slug": "جراحی-جنرال", + "status": 1, + "weight": 0, + "parent_id": 13 } -] \ No newline at end of file +] diff --git a/helper/index.js b/helper/index.js index bd8e52d..9a4832a 100644 --- a/helper/index.js +++ b/helper/index.js @@ -267,20 +267,53 @@ export function clearDoctorClinicParams(router, slug) { const newUrl = `/clinic/${clinicslug}`; router.push(newUrl) } +/** گزینهٔ «کل گروه» در فهرست زیرتخصص‌ها؛ با id تهی، چون حالتش نبودِ زیرتخصص است. */ +export const allOfCategoryOption = (category) => ({ + id: null, + name: `همه تخصص‌های ${category.name}`, + isAll: true, +}); + +/** + * فیلتر بعدی وقتی کاربر گروه یا زیرتخصص را عوض می‌کند. + * + * تابع خالص است چون همین قاعده بود که شکسته بود: انتخاب یک گروه بی‌صدا اولین + * زیرتخصصش را می‌نشاند و جستجوی کاربر را تنگ می‌کرد. داخل کامپوننت، تست‌پذیر نبود. + */ +export const nextSpecialtyFilter = (filter, name, value) => { + const next = { ...filter, [name]: value }; + + // گروه یعنی کل گروه، نه اولین فرزندش. + if (name === "category") next.specialty = null; + + // «همه تخصص‌های X» زیرتخصص واقعی نیست؛ حالتش همان نبودِ زیرتخصص است. اگر خودش + // در فیلتر می‌نشست، نامش در URL می‌رفت و چون در specialties.json نیست، با رفرش + // یا اشتراک لینک بازسازی نمی‌شد. + if (name === "specialty" && value?.isAll) next.specialty = null; + + return next; +}; + export const filterList = (data) => { const parentList = specialties.filter((item) => !item.parent_id); const childrenList = specialties.filter((item) => item.parent_id); - const filteredChildrenList = - data && data.category - ? childrenList.filter( - (item) => String(item.parent_id) === String(data.category.id) - ) - : []; + if (!data?.category) { + return { parentList, childrenList: [] }; + } + const ofCategory = childrenList.filter( + (item) => String(item.parent_id) === String(data.category.id) + ); + + // «همه» اول فهرست می‌نشیند تا برگشت از یک زیرتخصص به کل گروه ممکن باشد. + // خودش وارد فیلتر نمی‌شود؛ انتخابش یعنی specialty تهی، و آن‌وقت QueryForDoctorsReq + // به category.id برمی‌گردد که بک‌اند به همهٔ زیرشاخه‌ها گسترشش می‌دهد. return { - parentList: parentList, - childrenList: filteredChildrenList, + parentList, + childrenList: ofCategory.length + ? [allOfCategoryOption(data.category), ...ofCategory] + : [], }; }; diff --git a/helper/specialtyFilter.test.js b/helper/specialtyFilter.test.js new file mode 100644 index 0000000..af3f407 --- /dev/null +++ b/helper/specialtyFilter.test.js @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import specialtiesData from "@/data/specialties.json"; +import { + QueryForDoctorsFilter, + QueryForDoctorsReq, + filterList, + nextSpecialtyFilter, +} from "@/helper"; + +const byName = (name) => specialtiesData.find((s) => s.name === name); + +const GENERAL_SURGERY = byName("جراحی عمومی"); + +describe("filterList — گزینهٔ «همه تخصص‌های X»", () => { + it("بدون گروه، فهرست زیرتخصص خالی است", () => { + expect(filterList({}).childrenList).toEqual([]); + expect(filterList(null).childrenList).toEqual([]); + }); + + it("با گروه، «همه» اولین گزینه است و id گروه را ندارد", () => { + const { childrenList } = filterList({ category: GENERAL_SURGERY }); + + expect(childrenList[0]).toMatchObject({ + id: null, + isAll: true, + name: `همه تخصص‌های ${GENERAL_SURGERY.name}`, + }); + }); + + it("زیرتخصص‌های واقعی بعد از «همه» می‌آیند", () => { + const { childrenList } = filterList({ category: GENERAL_SURGERY }); + const real = childrenList.slice(1); + + expect(real.length).toBeGreaterThan(0); + expect( + real.every((s) => String(s.parent_id) === String(GENERAL_SURGERY.id)) + ).toBe(true); + }); + + it("زیرتخصص‌های تازهٔ همگام‌شده هم در فهرست هستند", () => { + // این پنج تا در اسنپ‌شات دستیِ قبلی نبودند؛ اگر همگام‌سازی برگردد، اینجا قرمز می‌شود. + const names = filterList({ category: GENERAL_SURGERY }).childrenList.map( + (s) => s.name + ); + + expect(names).toContain("جراح گوارش"); + expect(names).toContain("جراح تیروئید"); + }); + + it("گروهِ بدون زیرشاخه گزینهٔ «همه» نمی‌گیرد", () => { + const leafRoot = specialtiesData.find( + (s) => + !s.parent_id && + !specialtiesData.some((c) => String(c.parent_id) === String(s.id)) + ); + + expect(filterList({ category: leafRoot }).childrenList).toEqual([]); + }); + + it("فهرست گروه‌ها فقط ریشه‌هاست", () => { + expect(filterList({}).parentList.every((s) => !s.parent_id)).toBe(true); + }); +}); + +describe("nextSpecialtyFilter — قاعدهٔ انتخاب", () => { + it("انتخاب گروه، زیرتخصص را تهی می‌گذارد نه اولین فرزند", () => { + const firstChild = filterList({ category: GENERAL_SURGERY }).childrenList[1]; + const next = nextSpecialtyFilter({}, "category", GENERAL_SURGERY); + + expect(next.category).toBe(GENERAL_SURGERY); + expect(next.specialty).toBeNull(); + expect(next.specialty).not.toEqual(firstChild); + }); + + it("عوض کردن گروه، زیرتخصصِ گروه قبلی را پاک می‌کند", () => { + const child = filterList({ category: GENERAL_SURGERY }).childrenList[1]; + const other = specialtiesData.find( + (s) => !s.parent_id && s.id !== GENERAL_SURGERY.id + ); + + const next = nextSpecialtyFilter( + { category: GENERAL_SURGERY, specialty: child }, + "category", + other + ); + + expect(next.specialty).toBeNull(); + }); + + it("انتخاب «همه» زیرتخصص را تهی می‌کند", () => { + const all = filterList({ category: GENERAL_SURGERY }).childrenList[0]; + const next = nextSpecialtyFilter( + { category: GENERAL_SURGERY }, + "specialty", + all + ); + + expect(next.specialty).toBeNull(); + }); + + it("انتخاب یک زیرتخصص واقعی همان را می‌نشاند", () => { + const child = filterList({ category: GENERAL_SURGERY }).childrenList[1]; + const next = nextSpecialtyFilter( + { category: GENERAL_SURGERY }, + "specialty", + child + ); + + expect(next.specialty).toBe(child); + }); + + it("پاک کردن گروه هر دو را تهی می‌کند", () => { + const next = nextSpecialtyFilter( + { category: GENERAL_SURGERY, specialty: { id: 1 } }, + "category", + null + ); + + expect(next.category).toBeNull(); + expect(next.specialty).toBeNull(); + }); + + it("فیلترهای دیگر دست‌نخورده می‌مانند", () => { + const next = nextSpecialtyFilter( + { city: { id: 7 }, gender: "زن" }, + "category", + GENERAL_SURGERY + ); + + expect(next.city).toEqual({ id: 7 }); + expect(next.gender).toBe("زن"); + }); +}); + +describe("انتخاب کل گروه — پارامترها", () => { + it("بدون زیرتخصص، درخواست با شناسهٔ گروه می‌رود", () => { + // بک‌اند specialty_id را به همهٔ زیرشاخه‌ها گسترش می‌دهد. + const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: null }); + + expect(params.specialty_id).toBe(GENERAL_SURGERY.id); + }); + + it("با زیرتخصص، همان زیرتخصص بر گروه مقدم است", () => { + const child = filterList({ category: GENERAL_SURGERY }).childrenList[1]; + const params = QueryForDoctorsReq({ category: GENERAL_SURGERY, specialty: child }); + + expect(params.specialty_id).toBe(child.id); + }); + + it("URL نام گروه را می‌گیرد، نه عنوان مصنوعیِ «همه …»", () => { + // عنوان «همه تخصص‌های …» در specialties.json نیست؛ اگر در URL می‌نشست، + // با رفرش یا اشتراک لینک بازسازی نمی‌شد. + const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: null }); + + expect(query.specialty).toBe(GENERAL_SURGERY.name); + expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true); + }); + + it("نامِ رفته در URL همیشه در فایل تخصص‌ها پیدا می‌شود", () => { + const child = filterList({ category: GENERAL_SURGERY }).childrenList[1]; + const query = QueryForDoctorsFilter({ category: GENERAL_SURGERY, specialty: child }); + + expect(specialtiesData.some((s) => s.name === query.specialty)).toBe(true); + }); +}); diff --git a/lib/specialtyDisplay.js b/lib/specialtyDisplay.js new file mode 100644 index 0000000..50ab45a --- /dev/null +++ b/lib/specialtyDisplay.js @@ -0,0 +1,58 @@ +/** + * قاعدهٔ مشترک نمایش تخصص‌های پزشک — منبع واحد کارت و پوستر. + * + * پزشک می‌تواند چند تخصص داشته باشد و معمولاً هم دارد: ذخیرهٔ یک زیرتخصص در + * بک‌اند والدهایش را هم می‌نشاند، پس «جراح گوارش» عملاً یعنی «جراحی عمومی» + + * زیرشاخه. چاپ همهٔ نام‌ها پشت‌سرهم، در موبایل ارتفاع کارت را باد می‌کند و در + * پوستر از کادر ثابت بیرون می‌زند. + * + * نمایش در دو جا متفاوت است — کارت `+N` کلیک‌شدنی دارد و پوستر ندارد — اما + * «کدام تخصص اصلی است» یک قاعده بیشتر نیست و اینجا می‌ماند. + */ + +const SEPARATOR = " · "; + +/** + * تخصص «اصلی» و بقیه. + * + * ریشه (بدون `parent_id`) اصلی است: عنوانی است که بیمار می‌شناسد و چون هنگام + * ذخیره خودکار اضافه می‌شود تقریباً همیشه وجود دارد. اگر پزشکی فقط زیرتخصص داشت، + * اولین آیتم آرایه. + * + * @param {Array<{name?: string, parent_id?: string|number|null}>} list + * @returns {{primary: object|null, rest: Array}} + */ +export function splitSpecialties(list) { + const items = (list ?? []).filter((s) => s?.name); + if (items.length === 0) return { primary: null, rest: [] }; + + const primary = items.find((s) => s.parent_id == null) ?? items[0]; + + return { primary, rest: items.filter((s) => s !== primary) }; +} + +/** + * زیرتخصص‌ها به‌شکل یک خط متنی، بریده روی مرز کلمه با بودجهٔ کاراکتر. + * + * پوستر کادر ثابت ۱۰۸۰×۱۳۵۰ با `overflow-hidden` دارد؛ هر ردیف اضافه بخش‌های + * پایین را بیرون می‌اندازد. برش بر اساس طول متن است نه تعداد ثابت، چون + * «جراحی لاپاراسکوپی» و «قلب» یک‌اندازه جا نمی‌گیرند. + * + * @param {Array<{name?: string}>} rest + * @param {number} budget حداکثر کاراکترِ خط + * @returns {{text: string, hidden: number}} + */ +export function posterSpecialtyLine(rest, budget = 90) { + const names = (rest ?? []).filter((s) => s?.name).map((s) => s.name); + const shown = []; + let used = 0; + + for (const name of names) { + const cost = name.length + (shown.length ? SEPARATOR.length : 0); + if (shown.length && used + cost > budget) break; + shown.push(name); + used += cost; + } + + return { text: shown.join(SEPARATOR), hidden: names.length - shown.length }; +} diff --git a/lib/specialtyDisplay.test.js b/lib/specialtyDisplay.test.js new file mode 100644 index 0000000..f147fde --- /dev/null +++ b/lib/specialtyDisplay.test.js @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay"; + +const root = { id: "13", name: "جراحی عمومی", parent_id: null }; +const gi = { id: "169", name: "جراح گوارش", parent_id: "13" }; +const thyroid = { id: "168", name: "جراح تیروئید", parent_id: "13" }; + +describe("splitSpecialties", () => { + it("ریشه اصلی است، حتی اگر اول آرایه نباشد", () => { + const { primary, rest } = splitSpecialties([gi, root, thyroid]); + + expect(primary).toBe(root); + expect(rest).toEqual([gi, thyroid]); + }); + + it("بدون ریشه، اولین آیتم اصلی است", () => { + const { primary, rest } = splitSpecialties([gi, thyroid]); + + expect(primary).toBe(gi); + expect(rest).toEqual([thyroid]); + }); + + it("تک‌تخصص، بقیه خالی", () => { + const { primary, rest } = splitSpecialties([root]); + + expect(primary).toBe(root); + expect(rest).toEqual([]); + }); + + it("آرایهٔ خالی و ورودی تهی → primary تهی", () => { + expect(splitSpecialties([])).toEqual({ primary: null, rest: [] }); + expect(splitSpecialties(null)).toEqual({ primary: null, rest: [] }); + expect(splitSpecialties(undefined)).toEqual({ primary: null, rest: [] }); + }); + + it("آیتم بدون نام حذف می‌شود", () => { + const { primary, rest } = splitSpecialties([{ id: "1" }, root, gi]); + + expect(primary).toBe(root); + expect(rest).toEqual([gi]); + }); + + it("parent_id تهیِ رشته‌ای هم ریشه شمرده می‌شود", () => { + // بک‌اند parent_id را رشته می‌دهد؛ برابریِ سست عمدی است تا null و undefined + // هر دو ریشه باشند و 0 نباشد. + const { primary } = splitSpecialties([ + { id: "9", name: "زیرشاخه", parent_id: "13" }, + { id: "13", name: "ریشه", parent_id: undefined }, + ]); + + expect(primary.name).toBe("ریشه"); + }); + + it("دو ریشهٔ متفاوت → اولی اصلی، دومی در بقیه", () => { + const internal = { id: "2", name: "داخلی", parent_id: null }; + const { primary, rest } = splitSpecialties([root, internal, gi]); + + expect(primary).toBe(root); + expect(rest).toEqual([internal, gi]); + }); +}); + +describe("posterSpecialtyLine", () => { + it("نام‌ها را با جداکنندهٔ نقطه به هم می‌چسباند", () => { + const { text, hidden } = posterSpecialtyLine([gi, thyroid], 90); + + expect(text).toBe("جراح گوارش · جراح تیروئید"); + expect(hidden).toBe(0); + }); + + it("از بودجه که گذشت، بقیه را می‌شمارد نه می‌برد", () => { + const { text, hidden } = posterSpecialtyLine([gi, thyroid], 12); + + expect(text).toBe("جراح گوارش"); + expect(hidden).toBe(1); + }); + + it("برش روی مرز کلمه است، نه وسط نام", () => { + const { text } = posterSpecialtyLine([gi, thyroid], 15); + + expect(text.endsWith("…")).toBe(false); + expect(text).toBe("جراح گوارش"); + }); + + it("یک نام بلندتر از کل بودجه، بریده نمی‌شود", () => { + // خط خالی با «و ۱ تخصص دیگر» بدتر از یک نام کمی بلند است. + const long = { name: "جراحی لاپاراسکوپی پیشرفتهٔ کبد و مجاری صفراوی" }; + const { text, hidden } = posterSpecialtyLine([long], 10); + + expect(text).toBe(long.name); + expect(hidden).toBe(0); + }); + + it("آرایهٔ خالی و ورودی تهی → متن تهی و صفر پنهان", () => { + expect(posterSpecialtyLine([])).toEqual({ text: "", hidden: 0 }); + expect(posterSpecialtyLine(null)).toEqual({ text: "", hidden: 0 }); + }); + + it("آیتم بدون نام نه نمایش داده می‌شود نه شمرده", () => { + const { text, hidden } = posterSpecialtyLine([{ id: "1" }, gi], 90); + + expect(text).toBe("جراح گوارش"); + expect(hidden).toBe(0); + }); + + it("شش تخصصِ نمونهٔ واقعی از بودجهٔ پیش‌فرض نمی‌گذرد", () => { + const real = [ + { name: "جراحی پلاستیک و زیبایی" }, + { name: "جراحی لاپاراسکوپی" }, + { name: "جراح تیروئید" }, + { name: "جراح گوارش" }, + { name: "جراحی سرطانها" }, + ]; + const { text, hidden } = posterSpecialtyLine(real); + + expect(text.length).toBeLessThanOrEqual(90); + expect(hidden).toBe(real.length - text.split(" · ").length); + }); +}); diff --git a/package.json b/package.json index 37b2b5e..8d50bca 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "scripts": { "dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev", + "prebuild": "node scripts/sync-specialties.mjs", "build": "next build", "start": "next start", "lint": "eslint .", diff --git a/scripts/sync-specialties.mjs b/scripts/sync-specialties.mjs new file mode 100644 index 0000000..6020619 --- /dev/null +++ b/scripts/sync-specialties.mjs @@ -0,0 +1,80 @@ +/** + * همگام‌سازی `data/specialties.json` با دیتابیس، در زمان build. + * + * node scripts/sync-specialties.mjs + * + * فایل قبلاً یک اسنپ‌شات دستی بود و عقب می‌ماند: تخصصی که ادمین می‌ساخت در سایت + * نبود، پس صفحه‌اش ۴۰۴ می‌شد، در سایت‌مپ نمی‌آمد، و breadcrumb صفحهٔ پزشک به آن + * لینک نمی‌داد. نُه مصرف‌کننده از همین فایل می‌خوانند، از `app/sitemap.js` تا + * `helper/filterList`، پس منبع باید یکی و تازه بماند. + * + * فایل عمداً حذف نشد و runtime fetch جایش نیامد: `sitemap` و صفحات تخصص باید + * ایستا بمانند و به دسترس‌بودن API در زمان درخواست گره نخورند. + * + * فقط به `build` وصل است، نه `dev` — توسعهٔ آفلاین نباید به API نیاز داشته باشد. + * + * بک‌اند محلی گواهی self-signed دارد، پس اجرای محلی مثل اسکریپت `dev` نیاز دارد: + * + * NODE_TLS_REJECT_UNAUTHORIZED=0 npm run build + * + * این متغیر عمداً داخل `prebuild` ست نشده تا build تولیدی اعتبارسنجی TLS را از + * دست ندهد. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = join(ROOT, 'data/specialties.json'); + +/** + * `.env` را Next می‌خواند، نه یک اسکریپت خام node. اینجا فقط برای اجرای محلی + * خوانده می‌شود و متغیرهای واقعیِ محیط را بازنویسی نمی‌کند — در CI فایل نیست و + * مقدار از پلتفرم می‌آید. + */ +function loadDotEnv() { + const file = join(ROOT, '.env'); + if (!existsSync(file)) return; + + for (const line of readFileSync(file, 'utf8').split('\n')) { + const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (!match) continue; + + const [, key, raw] = match; + if (process.env[key] === undefined) { + process.env[key] = raw.replace(/^["']|["']$/g, ''); + } + } +} + +loadDotEnv(); + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +if (!API_URL) { + throw new Error('sync-specialties: NEXT_PUBLIC_API_URL is not set'); +} + +// `GET /api/v1/specialties` عمومی است و توکن نمی‌خواهد؛ بدون پارامتر `parent_id` +// همهٔ تخصص‌های فعال را می‌دهد، ریشه‌ها و فرزندان با هم. پاسخ دولایه است. +const res = await fetch(`${API_URL}/api/v1/specialties`); +if (!res.ok) { + throw new Error(`sync-specialties: HTTP ${res.status} from ${API_URL}`); +} + +const items = (await res.json())?.data?.data; + +// لیست خالی یعنی چیزی غلط است — پاسخ عوض شده، دیتابیس خالی است، یا پشت پراکسی +// نشسته‌ایم. بازنویسی با آن، سایت‌مپ را آب می‌کند و صفحات تخصص را از ایندکس +// می‌اندازد؛ شکستِ build از آن به‌مراتب ارزان‌تر است. +if (!Array.isArray(items) || items.length === 0) { + throw new Error('sync-specialties: empty list; refusing to overwrite data/specialties.json'); +} + +// ترتیب پایدار بر اساس id تا diff فایل نویزی نشود و مرور تغییرات ممکن بماند. +const sorted = [...items].sort((a, b) => a.id - b.id); + +const before = JSON.parse(readFileSync(OUT, 'utf8')); +writeFileSync(OUT, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8'); + +console.log(`sync-specialties: ${before.length} → ${sorted.length} specialties from ${API_URL}`);