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:
@@ -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)}
|
||||
</h1>
|
||||
|
||||
<div className="w-full flex flex-wrap justify-center gap-[10px] mt-[16px]">
|
||||
{specialties.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex items-center shrink-0 rounded-full bg-[#E3EEF9] px-[20px] py-[9px]"
|
||||
>
|
||||
{primary && (
|
||||
<div className="w-full flex justify-center mt-[16px]">
|
||||
<span 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">
|
||||
{s.name}
|
||||
{primary.name}
|
||||
</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 && (
|
||||
<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 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 }) {
|
||||
<h1 className="text-white text-[46px] font-extrabold leading-tight">
|
||||
{doctorTitle(data?.name)}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-[10px] mt-[16px]">
|
||||
{specialties.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex items-center rounded-full bg-[#F59E0B]/18 border border-[#F59E0B]/40 px-[18px] py-[9px]"
|
||||
>
|
||||
{primary && (
|
||||
<div className="mt-[16px] flex flex-wrap items-center gap-[12px]">
|
||||
<span 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">
|
||||
{s.name}
|
||||
{primary.name}
|
||||
</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]">
|
||||
{badges.map((b, i) => (
|
||||
<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";
|
||||
|
||||
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);
|
||||
|
||||
@@ -24,9 +24,13 @@ function Form({ filter, changeSpecialty, updateData }) {
|
||||
<CateSelector
|
||||
name="specialty"
|
||||
list={childrenList}
|
||||
updateData={updateData}
|
||||
updateData={changeSpecialty}
|
||||
value={filter.specialty}
|
||||
label="تخصص"
|
||||
// خالیبودن یعنی کل گروه؛ برچسب همین را میگوید تا کاربر فکر نکند
|
||||
// چیزی را جا انداخته.
|
||||
label={
|
||||
filter.category ? `همه تخصصهای ${filter.category.name}` : "تخصص"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-[40px] mb-[32px]">
|
||||
|
||||
@@ -55,7 +55,7 @@ function SearchField({ filter, setFilter, setDataInURL, sendReq }) {
|
||||
clearText={clearText}
|
||||
inputValue={filter.name}
|
||||
handleSearch={handleSearch}
|
||||
placeholder="جستجوی نام پزشک ..."
|
||||
placeholder="جستجوی نام پزشک یا تخصص ..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user