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.
This commit is contained in:
@@ -5,6 +5,7 @@ import CustomLoading from "./loading/Custom";
|
|||||||
import TextLoading from "./loading/Text";
|
import TextLoading from "./loading/Text";
|
||||||
import CircularLoading from "./loading/Circular";
|
import CircularLoading from "./loading/Circular";
|
||||||
import DoctorAvatar from "./DoctorAvatar";
|
import DoctorAvatar from "./DoctorAvatar";
|
||||||
|
import SpecialtyChips from "./SpecialtyChips";
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
import ArrowLeftD from "@/components/icons/ArrowLeftD";
|
||||||
@@ -44,13 +45,7 @@ function ItemDoctor({ doctor, loading, setDoctors, priority = false }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<TextLoading loading={loading} width={120} height={15}>
|
<TextLoading loading={loading} width={120} height={15}>
|
||||||
<p className="text-[#616161] text-[14px] font-normal">
|
<SpecialtyChips specialties={doctor?.specialties} />
|
||||||
تخصص:
|
|
||||||
{doctor?.specialties?.map(
|
|
||||||
(item, idx) =>
|
|
||||||
`${item.name} ${doctor.specialties.length === idx + 1 ? "" : "|"} `
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</TextLoading>
|
</TextLoading>
|
||||||
<CustomLoading loading={loading} width={100} height={25}>
|
<CustomLoading loading={loading} width={100} height={25}>
|
||||||
<div className="flex rounded items-center justify-start gap-2">
|
<div className="flex rounded items-center justify-start gap-2">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
// کارت داخل <Link> است؛ بدون این دو، هر کلیک روی شمارنده صفحهٔ پزشک را باز میکند.
|
||||||
|
const handleOpen = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (event) => {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="text-[#616161] text-[14px] font-normal truncate">
|
||||||
|
تخصص: {primary.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
aria-label={`نمایش ${rest.length} تخصص دیگر`}
|
||||||
|
className="shrink-0 p-1 bg-[#F8F8FF] rounded-[4px] text-[#616161] text-[12px] font-medium leading-none"
|
||||||
|
>
|
||||||
|
+{rest.length}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
dir="rtl"
|
||||||
|
className="p-3 flex flex-col gap-1.5 max-w-[260px] bg-[#FFF]"
|
||||||
|
>
|
||||||
|
{[primary, ...rest].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id ?? item.name}
|
||||||
|
className="text-[#616161] text-[13px] font-normal"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpecialtyChips;
|
||||||
@@ -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(<SpecialtyChips specialties={[gi, root, thyroid]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" })).toHaveTextContent("+2");
|
||||||
|
|
||||||
|
// بقیه تا پیش از کلیک در DOM نیستند — همین کارت را کوتاه نگه میدارد.
|
||||||
|
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کلیک روی شمارنده فهرست کامل را باز میکند", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "نمایش 2 تخصص دیگر" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("جراح گوارش")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("جراح تیروئید")).toBeInTheDocument();
|
||||||
|
// تخصص اصلی هم در فهرست هست تا تصویر کامل باشد.
|
||||||
|
expect(screen.getAllByText(/جراحی عمومی/).length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کلیک روی شمارنده نباید لینک کارت را فعال کند", () => {
|
||||||
|
// کارت داخل <Link> است؛ بدون preventDefault/stopPropagation هر بار که کاربر
|
||||||
|
// تخصصها را میبیند به صفحهٔ پزشک پرت میشود.
|
||||||
|
const onParentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Link href="/doctor/uuid" onClick={onParentClick}>
|
||||||
|
<SpecialtyChips specialties={[root, gi]} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// fireEvent مقدار false میدهد وقتی preventDefault صدا زده شده باشد.
|
||||||
|
const notPrevented = fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "نمایش 1 تخصص دیگر" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notPrevented).toBe(false);
|
||||||
|
expect(onParentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("تکتخصص هیچ شمارندهای ندارد", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/جراحی عمومی/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("پزشک بدون تخصص چیزی رندر نمیکند و کرش نمیکند", () => {
|
||||||
|
const { container: empty } = render(<SpecialtyChips specialties={[]} />);
|
||||||
|
expect(empty).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
const { container: missing } = render(<SpecialtyChips specialties={undefined} />);
|
||||||
|
expect(missing).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("شمارنده متن دارد نه فقط علامت — برای صفحهخوان", () => {
|
||||||
|
render(<SpecialtyChips specialties={[root, gi, thyroid]} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveAttribute("type", "button");
|
||||||
|
expect(button).toHaveAccessibleName("نمایش 2 تخصص دیگر");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar";
|
|||||||
import QrImg from "./QrImg";
|
import QrImg from "./QrImg";
|
||||||
import SiteLogo from "./SiteLogo";
|
import SiteLogo from "./SiteLogo";
|
||||||
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
||||||
|
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
|
||||||
import { doctorTitle } from "@/helper";
|
import { doctorTitle } from "@/helper";
|
||||||
|
|
||||||
import StarDI from "@/components/icons/StarDI";
|
import StarDI from "@/components/icons/StarDI";
|
||||||
@@ -21,7 +22,9 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
setCity(matchedCity || null);
|
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 services = (data?.expertise ?? []).filter((s) => s?.name);
|
||||||
const shownServices = services.slice(0, 8);
|
const shownServices = services.slice(0, 8);
|
||||||
const moreServices = services.length - shownServices.length;
|
const moreServices = services.length - shownServices.length;
|
||||||
@@ -88,18 +91,23 @@ function PosterLight({ data, onQrReady }) {
|
|||||||
{doctorTitle(data?.name)}
|
{doctorTitle(data?.name)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full flex flex-wrap justify-center gap-[10px] mt-[16px]">
|
{primary && (
|
||||||
{specialties.map((s, i) => (
|
<div className="w-full flex justify-center mt-[16px]">
|
||||||
<span
|
<span className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]">
|
||||||
key={i}
|
|
||||||
className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]"
|
|
||||||
>
|
|
||||||
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
|
<span className="text-[#16528C] text-[23px] font-semibold leading-none whitespace-nowrap">
|
||||||
{s.name}
|
{primary.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* زیرتخصصها یک خط متنی — در چاپ نه کلیک هست نه Popover. */}
|
||||||
|
{subSpecialties.text && (
|
||||||
|
<p className="w-full mt-[12px] max-h-[80px] overflow-hidden text-[#3E6E9E] text-[20px] font-medium leading-[40px]">
|
||||||
|
{subSpecialties.text}
|
||||||
|
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
|
<div className="w-full flex flex-wrap justify-center items-center gap-[14px] mt-[22px]">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import DoctorAvatar from "@/app/component/DoctorAvatar";
|
|||||||
import QrImg from "./QrImg";
|
import QrImg from "./QrImg";
|
||||||
import SiteLogoBase from "./SiteLogo";
|
import SiteLogoBase from "./SiteLogo";
|
||||||
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
import { getStateInfoClient } from "@/lib/getStateInfoClient";
|
||||||
|
import { posterSpecialtyLine, splitSpecialties } from "@/lib/specialtyDisplay";
|
||||||
import { doctorTitle } from "@/helper";
|
import { doctorTitle } from "@/helper";
|
||||||
|
|
||||||
import StarDI from "@/components/icons/StarDI";
|
import StarDI from "@/components/icons/StarDI";
|
||||||
@@ -41,7 +42,10 @@ function Poster({ data, onQrReady }) {
|
|||||||
setCity(matchedCity || null);
|
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 services = (data?.expertise ?? []).filter((s) => s?.name);
|
||||||
const shownServices = services.slice(0, 8);
|
const shownServices = services.slice(0, 8);
|
||||||
const moreServices = services.length - shownServices.length;
|
const moreServices = services.length - shownServices.length;
|
||||||
@@ -128,18 +132,25 @@ function Poster({ data, onQrReady }) {
|
|||||||
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
||||||
{doctorTitle(data?.name)}
|
{doctorTitle(data?.name)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
{primary && (
|
||||||
{specialties.map((s, i) => (
|
<div className="mt-[16px] flex flex-wrap items-center gap-[12px]">
|
||||||
<span
|
<span className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]">
|
||||||
key={i}
|
|
||||||
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
|
||||||
>
|
|
||||||
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
<span className="text-[#FCD9A0] text-[22px] font-semibold leading-none">
|
||||||
{s.name}
|
{primary.name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* زیرتخصصها یک خط متنیاند نه چیپ: در چاپ نه کلیک هست نه Popover، پس
|
||||||
|
«+N» بیمعناست و متن خوانا جایش را میگیرد. max-h تور ایمنیِ کادر است
|
||||||
|
اگر بودجهٔ کاراکتر روزی کوتاه تنظیم شود. */}
|
||||||
|
{subSpecialties.text && (
|
||||||
|
<p className="mt-[12px] max-h-[80px] overflow-hidden text-[#C7DBF2] text-[20px] font-medium leading-[40px]">
|
||||||
|
{subSpecialties.text}
|
||||||
|
{subSpecialties.hidden > 0 && ` و ${subSpecialties.hidden} تخصص دیگر`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-[12px] mt-[18px]">
|
<div className="flex flex-wrap gap-[12px] mt-[18px]">
|
||||||
{badges.map((b, i) => (
|
{badges.map((b, i) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Poster from "@/components/doctor/poster";
|
||||||
|
import PosterLight from "@/components/doctor/poster/PosterLight";
|
||||||
|
|
||||||
|
vi.mock("@/lib/getStateInfoClient", () => ({
|
||||||
|
getStateInfoClient: () => ({ matchedCity: { site_name: "یاسوج نوبت", domain: "yasuj-nobat.ir" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./QrImg", () => ({ default: () => <div data-testid="qr" /> }));
|
||||||
|
vi.mock("@/components/doctor/poster/QrImg", () => ({ default: () => <div data-testid="qr" /> }));
|
||||||
|
|
||||||
|
// همان شش تخصص دکتر محمدباقر جهانتاب، با همان شکلی که 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(<Component data={JAHANTAB} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("زیرتخصصها یک خط متنیاند، نه چیپ جداگانه", () => {
|
||||||
|
render(<Component data={JAHANTAB} />);
|
||||||
|
|
||||||
|
// اگر چیپ بودند، هر نام یک گرهٔ متنی مستقل داشت.
|
||||||
|
expect(screen.queryByText("جراح گوارش")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/جراح گوارش/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("مازاد را میشمارد و نمیبرد", () => {
|
||||||
|
render(<Component data={JAHANTAB} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("کادر ثابت ۱۰۸۰×۱۳۵۰ و overflow-hidden دستنخورده است", () => {
|
||||||
|
const { container } = render(<Component data={JAHANTAB} />);
|
||||||
|
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(<Component data={JAHANTAB} />);
|
||||||
|
|
||||||
|
const line = screen.getByText(/جراح گوارش/);
|
||||||
|
expect(line.textContent.length).toBeLessThan(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("پزشک تکتخصص خط زیرتخصص ندارد", () => {
|
||||||
|
render(
|
||||||
|
<Component
|
||||||
|
data={{ ...JAHANTAB, specialties: [{ id: "13", name: "جراحی عمومی", parent_id: null }] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("جراحی عمومی")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/تخصص دیگر/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("پزشک بدون تخصص کرش نمیکند", () => {
|
||||||
|
expect(() => render(<Component data={{ ...JAHANTAB, specialties: [] }} />)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
import specialties from "@/data/specialties.json";
|
import { nextSpecialtyFilter } from "@/helper";
|
||||||
import Form from "./form";
|
import Form from "./form";
|
||||||
|
|
||||||
function Content({ filter, setFilter, updateData, setDataInURL }) {
|
function Content({ filter, setFilter, updateData, setDataInURL }) {
|
||||||
const changeSpecialty = (name, value) => {
|
const changeSpecialty = (name, value) => {
|
||||||
const newFilter = { ...filter, [name]: value };
|
const newFilter = nextSpecialtyFilter(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilter(newFilter);
|
setFilter(newFilter);
|
||||||
setDataInURL(newFilter);
|
setDataInURL(newFilter);
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
|
|||||||
<CateSelector
|
<CateSelector
|
||||||
name="specialty"
|
name="specialty"
|
||||||
list={childrenList}
|
list={childrenList}
|
||||||
updateData={updateData}
|
updateData={changeSpecialty}
|
||||||
value={filter.specialty}
|
value={filter.specialty}
|
||||||
label="تخصص"
|
// خالیبودن یعنی کل گروه؛ برچسب همین را میگوید تا کاربر فکر نکند
|
||||||
|
// چیزی را جا انداخته.
|
||||||
|
label={
|
||||||
|
filter.category ? `همه تخصصهای ${filter.category.name}` : "تخصص"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[40px] mb-[32px]">
|
<div className="mt-[40px] mb-[32px]">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
|
|||||||
clearText={clearText}
|
clearText={clearText}
|
||||||
inputValue={filter.name}
|
inputValue={filter.name}
|
||||||
handleSearch={handleSearch}
|
handleSearch={handleSearch}
|
||||||
placeholder="جستجوی نام پزشک ..."
|
placeholder="جستجوی نام پزشک یا تخصص ..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+46
-1
@@ -844,5 +844,50 @@
|
|||||||
"status": 1,
|
"status": 1,
|
||||||
"weight": 0,
|
"weight": 0,
|
||||||
"parent_id": 19
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+41
-8
@@ -267,20 +267,53 @@ export function clearDoctorClinicParams(router, slug) {
|
|||||||
const newUrl = `/clinic/${clinicslug}`;
|
const newUrl = `/clinic/${clinicslug}`;
|
||||||
router.push(newUrl)
|
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) => {
|
export const filterList = (data) => {
|
||||||
const parentList = specialties.filter((item) => !item.parent_id);
|
const parentList = specialties.filter((item) => !item.parent_id);
|
||||||
const childrenList = specialties.filter((item) => item.parent_id);
|
const childrenList = specialties.filter((item) => item.parent_id);
|
||||||
|
|
||||||
const filteredChildrenList =
|
if (!data?.category) {
|
||||||
data && data.category
|
return { parentList, childrenList: [] };
|
||||||
? childrenList.filter(
|
}
|
||||||
(item) => String(item.parent_id) === String(data.category.id)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
|
const ofCategory = childrenList.filter(
|
||||||
|
(item) => String(item.parent_id) === String(data.category.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// «همه» اول فهرست مینشیند تا برگشت از یک زیرتخصص به کل گروه ممکن باشد.
|
||||||
|
// خودش وارد فیلتر نمیشود؛ انتخابش یعنی specialty تهی، و آنوقت QueryForDoctorsReq
|
||||||
|
// به category.id برمیگردد که بکاند به همهٔ زیرشاخهها گسترشش میدهد.
|
||||||
return {
|
return {
|
||||||
parentList: parentList,
|
parentList,
|
||||||
childrenList: filteredChildrenList,
|
childrenList: ofCategory.length
|
||||||
|
? [allOfCategoryOption(data.category), ...ofCategory]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<object>}}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env HOST=yazd-nobat.localhost PORT=3000 NODE_TLS_REJECT_UNAUTHORIZED=0 next dev",
|
"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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
Reference in New Issue
Block a user