diff --git a/.claude/prompt/clinic-contact-info-fix.md b/.claude/prompt/clinic-contact-info-fix.md new file mode 100644 index 0000000..a6ffb83 --- /dev/null +++ b/.claude/prompt/clinic-contact-info-fix.md @@ -0,0 +1,268 @@ +# اصلاح بخش «اطلاعات تماس» صفحه کلینیک + +## پروژه + +`nobat724_front` + +## زمینه + +صفحه عمومی کلینیک (`/clinic/[uuid]`) یک کارت «اطلاعات تماس» دارد که روزهای کاری، تلفن، آدرس و نقشه را نشان می‌دهد. روی نمونه واقعی: + +`http://karaj-nobat.localhost:3000/clinic/bcb00726-2343-4d63-90c6-d0175cc74591` + +پاسخ واقعی API (`GET /api/v1/clinic/{uuid}` روی `https://clinic-pro.ddev.site`) این است: + +```json +{ + "name": "کلینیک تست QA", + "title": "کلینیک تست QA", + "phone": "03531234567", + "phone_number": "03531234567", + "city": [{ "id": "105", "name": "کرج", "parent": "5" }], + "state": [{ "id": "5", "name": "البرز" }], + "location": "یزد، خیابان تست QA", + "map": { "latitude": null, "longitude": null }, + "24_7": false, + "field_working_days": null +} +``` + +اطلاعات تماسی که روی صفحه رندر می‌شود با این داده هم‌خوان نیست. + +## مشکل / هدف + +۱. **شهر/استان اصلاً نمایش داده نمی‌شود.** API فیلدهای `city[0].name` و `state[0].name` را می‌فرستد ولی UI فقط رشتهٔ آزاد `location` را چاپ می‌کند. نتیجه: کاربر روی دامنهٔ کرج آدرس «یزد، خیابان تست QA» می‌بیند بدون هیچ نشانه‌ای از شهر واقعی کلینیک (کرج/البرز). + +۲. **تلفن خام رندر می‌شود.** `03531234567` بدون هیچ جداکننده‌ای نمایش داده می‌شود و مقدار `href="tel:..."` هم مستقیم از همان رشته ساخته می‌شود (اگر مقدار DB فاصله یا `-` داشته باشد، `tel:` خراب می‌شود). + +۳. **ناسازگاری منبع تلفن بین UI و JSON-LD.** کامپوننت `Detail.js` از `phone_number || phone` می‌خواند اما JSON-LD در `page.js` فقط `clinic.phone` را می‌خواند. اگر یکی پر و دیگری خالی باشد، صفحه و structured data دو چیز متفاوت می‌گویند. + +۴. **`PostalAddress` در JSON-LD ناقص است.** فقط `streetAddress` و `addressCountry` دارد؛ `addressLocality` (شهر) و `addressRegion` (استان) ندارند در حالی که داده‌اش موجود است. + +۵. **کارت خالی.** وقتی `field_working_days` و `phone` و `location` همه null باشند و مختصات هم نباشد، `Detail` مقدار `null` برمی‌گرداند و `mapQuery` خالی است — ولی کارت `
` همچنان رندر می‌شود و یک باکس خالی روی صفحه می‌ماند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `nobat724_front/components/clinic/components/contact/Detail.js` | رندر ردیف‌های روزهای کاری / تلفن / آدرس | +| `nobat724_front/components/clinic/components/contact/index.js` | کارت اطلاعات تماس + نقشه + دکمه مسیریابی | +| `nobat724_front/components/clinic/components/contact/ContentDetail.js` | ردیف `head`/`detail` (تغییر ندارد) | +| `nobat724_front/app/clinic/[slug]/page.js` | JSON-LD نوع `MedicalClinic` (فیلدهای `telephone` و `address`) | + +## وضعیت فعلی + +`components/clinic/components/contact/Detail.js`: + +```jsx +function Detail({ data }) { + const workingDays = data?.["24_7"] + ? "۲۴ ساعته، تمام روزهای هفته" + : data?.field_working_days?.trim(); + const phone = (data?.phone_number || data?.phone || "").trim(); + const address = data?.location?.trim(); + + if (!workingDays && !phone && !address) return null; + + return ( + + ); +} +``` + +`app/clinic/[slug]/page.js` (بخش JSON-LD): + +```js +...(clinic.phone && { telephone: clinic.phone }), +...(clinic.location && { + address: { + "@type": "PostalAddress", + streetAddress: clinic.location, + addressCountry: "IR", + }, +}), +``` + +## وظایف + +### ۱. ساخت helper مشترک برای اطلاعات تماس کلینیک + +فایل جدید `nobat724_front/lib/clinicContact.js` بساز تا هم UI و هم JSON-LD از یک منبع بخوانند (رفع مشکل ۳): + +```js +// lib/clinicContact.js + +/** API هر دو کلید را می‌فرستد؛ یکی ممکن است null باشد. یک منبع واحد. */ +export function getClinicPhone(clinic) { + return (clinic?.phone_number || clinic?.phone || "").toString().trim(); +} + +/** فقط رقم — برای href="tel:" تا فاصله/خط تیرهٔ داخل DB لینک را خراب نکند. */ +export function telHref(phone) { + const digits = (phone || "").replace(/[^\d+]/g, ""); + return digits ? `tel:${digits}` : null; +} + +/** 03531234567 → +983531234567 برای schema.org telephone */ +export function toE164Ir(phone) { + const d = (phone || "").replace(/\D/g, ""); + if (!d) return null; + if (d.startsWith("98")) return `+${d}`; + if (d.startsWith("0")) return `+98${d.slice(1)}`; + return `+98${d}`; +} + +export function getClinicCity(clinic) { + return clinic?.city?.[0]?.name?.trim() || null; +} + +export function getClinicState(clinic) { + return clinic?.state?.[0]?.name?.trim() || null; +} + +/** + * آدرس نمایشی: «استان، شهر — نشانی». + * اگر خودِ location قبلاً نام شهر را داشته باشد دوباره تکرار نمی‌شود. + */ +export function getClinicAddress(clinic) { + const street = clinic?.location?.trim() || ""; + const city = getClinicCity(clinic); + const state = getClinicState(clinic); + + const parts = []; + if (state && !street.includes(state)) parts.push(state); + if (city && !street.includes(city)) parts.push(city); + if (street) parts.push(street); + + return parts.length ? parts.join("، ") : null; +} + +/** آیا اصلاً چیزی برای نمایش در کارت تماس هست؟ */ +export function hasClinicContact(clinic) { + return Boolean( + clinic?.["24_7"] || + clinic?.field_working_days?.trim() || + getClinicPhone(clinic) || + getClinicAddress(clinic) || + (clinic?.map?.latitude && clinic?.map?.longitude) + ); +} +``` + +### ۲. اصلاح `Detail.js` + +- تلفن و آدرس را از helper بگیر. +- `tel:` را از `telHref()` بساز؛ اگر null بود فقط متن ساده رندر کن (نه لینک شکسته). +- شهر/استان را در ردیف آدرس نشان بده. + +```jsx +import ContentDetail from "./ContentDetail"; +import { getClinicPhone, telHref, getClinicAddress } from "@/lib/clinicContact"; + +function Detail({ data }) { + const workingDays = data?.["24_7"] + ? "۲۴ ساعته، تمام روزهای هفته" + : data?.field_working_days?.trim(); + const phone = getClinicPhone(data); + const href = telHref(phone); + const address = getClinicAddress(data); + + if (!workingDays && !phone && !address) return null; + + return ( + + ); +} +``` + +### ۳. جلوگیری از کارت خالی در `contact/index.js` + +در ابتدای `Contact`، اگر `hasClinicContact(data)` نادرست بود `null` برگردان تا باکس border-dar خالی رندر نشود: + +```jsx +import { hasClinicContact } from "@/lib/clinicContact"; + +function Contact({ data }) { + const [open, setOpen] = useState(false); + // ... + if (!hasClinicContact(data)) return null; + // ... +} +``` + +> هوک‌ها باید **قبل** از این return صدا زده شوند (قانون hooks) — `useState` را بالای شرط نگه دار. + +همچنین در ساخت `mapQuery`، به‌جای `data.location` از `getClinicAddress(data)` استفاده کن تا وقتی مختصات نیست، کوئری نقشه شامل شهر/استان باشد و پین به شهر درست بیفتد (نمونهٔ فعلی: `location = "یزد، خیابان تست QA"` ولی شهر واقعی «کرج» است — بدون شهر، نقشه یزد را نشان می‌دهد). + +### ۴. تکمیل JSON-LD در `app/clinic/[slug]/page.js` + +```js +import { + getClinicPhone, + toE164Ir, + getClinicCity, + getClinicState, +} from "@/lib/clinicContact"; + +// ... +const clinicPhone = getClinicPhone(clinic); +const clinicCity = getClinicCity(clinic); +const clinicState = getClinicState(clinic); + +const jsonLd = clinic ? { + // ... + ...(clinicPhone && { telephone: toE164Ir(clinicPhone) }), + ...((clinic.location || clinicCity) && { + address: { + "@type": "PostalAddress", + ...(clinic.location && { streetAddress: clinic.location.trim() }), + ...(clinicCity && { addressLocality: clinicCity }), + ...(clinicState && { addressRegion: clinicState }), + addressCountry: "IR", + }, + }), + // ... +} : null; +``` + +## نکات مهم + +- **Server/Client:** `contact/index.js` کلاینت است (`useState`)؛ `lib/clinicContact.js` باید pure و بدون وابستگی به `next/headers` بماند تا هم در Server Component (`page.js`) و هم در Client Component قابل import باشد. +- **مقدار `null` رشته‌ای:** API برای فیلدهای پرنشده `null` می‌فرستد؛ هیچ‌جا مستقیم داخل template string نگذار (کامنت موجود در `Detail.js` همین را هشدار می‌دهد) — همهٔ helperها باید `null` برگردانند نه رشتهٔ خالیِ درج‌شده. +- **`24_7` کلید عددی‌شروع است** — همیشه با bracket notation (`clinic["24_7"]`) خوانده شود. +- **تکرار شهر:** بعضی رکوردها نام شهر را داخل خود `location` دارند؛ منطق `getClinicAddress` باید تکرار را حذف کند (تست: `location="کرج، بلوار..."` + `city="کرج"` → خروجی نباید «کرج، کرج، بلوار...» باشد). +- **JSON-LD sanitize:** خروجی همچنان باید از `safeJsonLd()` عبور کند (الگوی فعلی `page.js`). +- **تغییر backend لازم نیست** — همهٔ فیلدها (`city`, `state`, `phone`, `phone_number`, `location`) در پاسخ فعلی API موجودند. +- **بررسی رگرسیون:** اگر صفحهٔ پزشک (`components/doctor/...`) هم آدرس کلینیک را همین‌طور رندر می‌کند، فقط گزارش بده — در این تسک تغییرش نده. diff --git a/app/clinic/[slug]/page.js b/app/clinic/[slug]/page.js index d0a87e2..a1865a4 100644 --- a/app/clinic/[slug]/page.js +++ b/app/clinic/[slug]/page.js @@ -9,6 +9,12 @@ import { extractEntityCityId } from "@/lib/domainHelpers"; import { isThinClinic } from "@/lib/entityQuality"; import { safeJsonLd } from "@/lib/sanitize"; import { imageUrl } from "@/helper"; +import { + getClinicPhone, + toE164Ir, + getClinicCity, + getClinicState, +} from "@/lib/clinicContact"; const API_URL = process.env.NEXT_PUBLIC_API_URL; @@ -114,6 +120,10 @@ async function Clinic({ params, searchParams }) { ? Object.values(clinic.social_media).filter(Boolean) : []; + const clinicPhone = getClinicPhone(clinic); + const clinicCity = getClinicCity(clinic); + const clinicState = getClinicState(clinic); + const jsonLd = clinic ? { "@context": "https://schema.org", @@ -127,11 +137,13 @@ async function Clinic({ params, searchParams }) { name: clinic.title, }, }), - ...(clinic.phone && { telephone: clinic.phone }), - ...(clinic.location && { + ...(clinicPhone && { telephone: toE164Ir(clinicPhone) }), + ...((clinic.location || clinicCity) && { address: { "@type": "PostalAddress", - streetAddress: clinic.location, + ...(clinic.location && { streetAddress: clinic.location.trim() }), + ...(clinicCity && { addressLocality: clinicCity }), + ...(clinicState && { addressRegion: clinicState }), addressCountry: "IR", }, }), diff --git a/components/clinic/components/contact/Detail.js b/components/clinic/components/contact/Detail.js index 85ab20b..ac3a17a 100644 --- a/components/clinic/components/contact/Detail.js +++ b/components/clinic/components/contact/Detail.js @@ -1,4 +1,5 @@ import ContentDetail from "./ContentDetail"; +import { getClinicPhone, telHref, getClinicAddress } from "@/lib/clinicContact"; function Detail({ data }) { // API برای فیلدهای پرنشده null می‌فرستد؛ درج مستقیم در template رشتهٔ «null» @@ -6,8 +7,9 @@ function Detail({ data }) { const workingDays = data?.["24_7"] ? "۲۴ ساعته، تمام روزهای هفته" : data?.field_working_days?.trim(); - const phone = (data?.phone_number || data?.phone || "").trim(); - const address = data?.location?.trim(); + const phone = getClinicPhone(data); + const href = telHref(phone); + const address = getClinicAddress(data); if (!workingDays && !phone && !address) return null; @@ -20,9 +22,13 @@ function Detail({ data }) { - {phone} - + href ? ( + + {phone} + + ) : ( + {phone} + ) } /> )} diff --git a/components/clinic/components/contact/index.js b/components/clinic/components/contact/index.js index ed76b1a..8d5e59d 100644 --- a/components/clinic/components/contact/index.js +++ b/components/clinic/components/contact/index.js @@ -1,4 +1,5 @@ import { useState } from "react"; +import dynamic from "next/dynamic"; import RoutingWhiteC from "@/components/icons/RoutingWhiteC"; import { Button } from "@mui/material"; @@ -6,20 +7,25 @@ import Detail from "./Detail"; import RoutingC from "@/components/icons/RoutingC"; import ModalOpenLocation from "@/app/component/openLocation"; import CustomLoading from "@/app/component/loading/Custom"; +import { hasClinicContact } from "@/lib/clinicContact"; + +// همان نقشهٔ صفحهٔ پزشک: Leaflet روی تایل‌های OpenStreetMap. embed گوگل‌مپ +// در ایران بارگذاری نمی‌شد و کادر نقشه خالی می‌ماند. +const MapView = dynamic(() => import("@/components/common/MapView"), { + ssr: false, +}); function Contact({ data }) { const [open, setOpen] = useState(false); const handleOpen = () => setOpen(true); const handleClose = () => setOpen(false); - // بدون مختصات و بدون نشانی، کوئری نقشه خالی می‌ماند و iframe نقطهٔ بی‌ربط - // نشان می‌دهد؛ در آن حالت نقشه و دکمهٔ مسیریابی اصلاً معنا ندارند. + // نقشه و مسیریابی هر دو مختصات می‌خواهند — لینک‌های مسیریابی (بلد/گوگل/ویز) + // بدون lat/lng به `undefined,undefined` می‌رسند. با نشانیِ متنی تنها، هیچ‌کدام + // معنا ندارد و فقط ردیف آدرس نمایش داده می‌شود. const hasCoords = Boolean(data?.map?.latitude && data?.map?.longitude); - const mapQuery = hasCoords - ? `${data.map.latitude},${data.map.longitude}` - : data?.location?.trim() - ? encodeURIComponent(data.location.trim()) - : ""; + + if (!hasClinicContact(data)) return null; return (
- {mapQuery && }
- {mapQuery &&
+ {hasCoords &&
- +