diff --git a/next.config.mjs b/next.config.mjs index 4678774..9a17e2c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,13 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.paziresh24.com", + }, + ], + }, +}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 01a03fd..923df7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "clsx": "^2.1.1", + "dompurify": "^3.2.4", "next": "14.2.20", "react": "^18", "react-dom": "^18", @@ -828,6 +829,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz", @@ -1659,6 +1666,14 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", diff --git a/package.json b/package.json index c476a36..47af989 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "clsx": "^2.1.1", + "dompurify": "^3.2.4", "next": "14.2.20", "react": "^18", "react-dom": "^18", diff --git a/src/app/doctor/[[...slug]]/components/about/about.component.tsx b/src/app/doctor/[[...slug]]/components/about/about.component.tsx new file mode 100644 index 0000000..0b1f1f8 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/about/about.component.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ReactNode } from "react"; + +import DOMPurify from "dompurify"; + +import CardComponent from "@/components/card/card.component"; + +import styles from "./about.module.css"; + +type Props = { + aboutText: string; +}; + +const AboutComponent: React.FC = ({ aboutText }): ReactNode => { + const sanitizedHTML = DOMPurify.sanitize(aboutText ?? ""); + + return ( +
+
+

درباره دکتر

+
+ +
+ +
+ ); +}; + +export default AboutComponent; diff --git a/src/app/doctor/[[...slug]]/components/about/about.module.css b/src/app/doctor/[[...slug]]/components/about/about.module.css new file mode 100644 index 0000000..2f7b68e --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/about/about.module.css @@ -0,0 +1,26 @@ +.container { + display: grid; + gap: 1rem; + + .title { + h2 { + font-size: var(--fz-300); + } + } + + .about div { + font-size: var(--fz-200); + + h2 { + font-size: var(--fz-300); + } + + > *:not(:first-child) { + margin-block-start: 1rem; + } + + ol { + margin-inline-start: 2rem; + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/activities/activities.component.tsx b/src/app/doctor/[[...slug]]/components/activities/activities.component.tsx new file mode 100644 index 0000000..587de5a --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/activities/activities.component.tsx @@ -0,0 +1,52 @@ +import { ReactElement } from "react"; + +import CardComponent from "@/components/card/card.component"; + +import MingcuteCommentLine from "@/icons/MingcuteCommentLine"; +import MingcuteAwardLine from "@/icons/MingcuteAwardLine"; + +import styles from "./activities.module.css"; + +type Props = { + doctorName: string; + activeCounsulate: number; +}; + +const ActivitiesComponent: React.FC = ({ + doctorName, + activeCounsulate, +}): ReactElement => { + return ( +
+
+

فعالیت‌ها

+
+ +
+
    +
  • + + + + {activeCounsulate.toLocaleString()} + +   + مشاوره فعال + +
  • + +
  • + + + پذیرش24 بیش از 2 سال و 11 ماه افتخار میزبانی از صفحه اختصاصی + دکتر {doctorName} را داشته است. + +
  • +
+
+
+
+ ); +}; + +export default ActivitiesComponent; diff --git a/src/app/doctor/[[...slug]]/components/activities/activities.module.css b/src/app/doctor/[[...slug]]/components/activities/activities.module.css new file mode 100644 index 0000000..c336640 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/activities/activities.module.css @@ -0,0 +1,39 @@ +.container { + display: grid; + gap: 1rem; + + .title { + h2 { + font-size: var(--fz-300); + } + } + + .activity { + ul li { + display: flex; + align-items: center; + gap: 0.5rem; + + background-color: var(--color-surface-400); + + font-size: var(--fz-200); + + border-radius: var(--border-radius); + + padding-inline: 1rem; + padding-block: 1rem; + + svg { + inline-size: 1.5rem; + block-size: 1.5rem; + } + .consulation_count { + font-weight: 600; + } + } + + ul li:not(:first-child) { + margin-block-start: 0.8rem; + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/characteristics/characteristics.component.tsx b/src/app/doctor/[[...slug]]/components/characteristics/characteristics.component.tsx new file mode 100644 index 0000000..59f644b --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/characteristics/characteristics.component.tsx @@ -0,0 +1,40 @@ +import { ReactElement } from "react"; + +import CardComponent from "@/components/card/card.component"; +import { MingcuteAIFill } from "@/icons/MingcuteAIFill"; + +import styles from "./characteristics.module.css"; + +type Props = { + doctorName: string; + characteristics: string[]; +}; + +const CharacteristicsComponent: React.FC = ({ + doctorName, + characteristics, +}): ReactElement => { + return ( +
+
+

ویژگی‌های دکتر {doctorName}

+
+ +
+
    + {characteristics?.map((characteristic, index) => { + return ( +
  • + + {characteristic} +
  • + ); + })} +
+
+
+
+ ); +}; + +export default CharacteristicsComponent; diff --git a/src/app/doctor/[[...slug]]/components/characteristics/characteristics.module.css b/src/app/doctor/[[...slug]]/components/characteristics/characteristics.module.css new file mode 100644 index 0000000..87171e2 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/characteristics/characteristics.module.css @@ -0,0 +1,41 @@ +.characteristics { + display: grid; + gap: 1rem; + + .title { + h2 { + font-size: var(--fz-300); + } + } + + .characteristics-item { + ul { + border-right: 2px dotted var(--color-gray-80); + + padding-inline-start: 0.75rem; + + font-size: var(--fz-200); + line-height: 1.5rem; + + li { + display: flex; + align-items: center; + gap: 0.5rem; + + svg { + inline-size: 1rem; + block-size: 1rem; + } + } + + li span { + font-weight: 600; + font-size: var(--fz-300); + } + } + + ul li:not(:first-child) { + margin-block-start: 0.5rem; + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/contact/contact.component.tsx b/src/app/doctor/[[...slug]]/components/contact/contact.component.tsx new file mode 100644 index 0000000..3652796 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/contact/contact.component.tsx @@ -0,0 +1,81 @@ +import { ReactNode } from "react"; + +import CardComponent from "@/components/card/card.component"; + +import MingcutePencilLine from "@/icons/MingcutePencilLine"; +import MingcuteCalendar2Line from "@/icons/MingcuteCalendar2Line"; +import MingcutePhoneLine from "@/icons/MingcutePhoneLine"; +import MingcuteLocationLine from "@/icons/MingcuteLocationLine"; + +import styles from "./contact.module.css"; + +type Props = { + address: string; + doctorName: string; +}; + +const extractCityAndAddress = (fullAddress: string) => { + const parts = fullAddress.split(/[,.،]/); + const city = parts[0]?.trim() || ""; + const remainingAddress = parts.slice(1).join(", ").trim(); + return { city, remainingAddress }; +}; + +const ContactComponent: React.FC = ({ + address, + doctorName, +}): ReactNode => { + const { city, remainingAddress } = extractCityAndAddress(address); + + return ( +
+
+

آدرس و تلفن تماس

+ +
+ + گزارش تلفن و آدرس صحیح +
+
+ + +
+ مطب دکتر {doctorName} +
+ {city} - + {remainingAddress} +
+ +
+
    +
  • + +
  • + +
  • + + + +
  • + +
  • + +
  • +
+
+
+
+
+ ); +}; + +export default ContactComponent; diff --git a/src/app/doctor/[[...slug]]/components/contact/contact.module.css b/src/app/doctor/[[...slug]]/components/contact/contact.module.css new file mode 100644 index 0000000..19cefe9 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/contact/contact.module.css @@ -0,0 +1,96 @@ +.container { + display: grid; + gap: 1rem; + + .title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + + h2 { + font-size: var(--fz-300); + } + + .report { + display: flex; + justify-content: space-end; + align-items: center; + gap: 0.3rem; + + font-size: var(--fz-100); + + color: var(--color-primary-darker); + + cursor: pointer; + + svg { + inline-size: 1rem; + block-size: 1rem; + } + } + } + + .contact { + background-color: var(--color-surface-400); + border-radius: var(--border-radius); + + padding: 1rem; + + strong { + display: inline-block; + font-size: var(--fz-300); + margin-block-end: 0.3rem; + } + + address { + font-style: normal; + font-size: var(--fz-200); + + span:first-of-type { + font-weight: bold; + } + } + + .action_btn { + ul li { + margin-block-start: 1rem; + + font-size: var(--fz-200); + font-weight: 600; + } + + button { + inline-size: 100%; + + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + + background-color: inherit; + color: var(--color-primary-darker); + + border-radius: 0.4rem; + border: 1px solid var(--color-primary); + + padding-block: 0.8rem; + padding-inline: 1rem; + + cursor: pointer; + + transition: 0.2s ease-in-out; + transition-property: background-color, color; + + &:hover { + background-color: var(--color-primary-fade); + } + + svg { + inline-size: 1.4rem; + block-size: 1.4rem; + } + } + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/counsulation/counsulation.component.tsx b/src/app/doctor/[[...slug]]/components/counsulation/counsulation.component.tsx new file mode 100644 index 0000000..eb1566c --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/counsulation/counsulation.component.tsx @@ -0,0 +1,54 @@ +import { ReactElement } from "react"; + +import CardComponent from "@/components/card/card.component"; + +import { MingcuteArrowLeftLine } from "@/icons/MingcuteArrowLeftLine"; +import OnlineTowCiecleFill from "@/icons/OnlineTowCiecleFill"; + +import styles from "./counsulation.module.css"; + +type Props = { + price: number; +}; + +const CounsulationComponent: React.FC = ({ price }): ReactElement => { + return ( + +
+
+
+ + همین الان آنلاین ویزیت شوید +
+ {price.toLocaleString()} تومان +
+ +
+
    +
  • ویزیت آنلاین در پیام رسان:
  • +
  • تضمین بازپرداخت مبلغ ویزیت در صورت نارضایتی
  • +
  • امکان برقراری تماس با این پزشک وجود دارد.
  • +
  • + تا   + ۳ روز +   می‌توانید هر سوالی دارید از پزشک بپرسید +
  • +
  • میانگین زمان انتظار تا ویزیت:
  • +
+
+ + +
+
+ ); +}; + +export default CounsulationComponent; diff --git a/src/app/doctor/[[...slug]]/components/counsulation/counsulation.module.css b/src/app/doctor/[[...slug]]/components/counsulation/counsulation.module.css new file mode 100644 index 0000000..ab08ff1 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/counsulation/counsulation.module.css @@ -0,0 +1,78 @@ +.card { + padding: 0 !important; +} + +.container { + display: grid; + gap: 0.5rem; + + .price { + display: flex; + justify-content: space-between; + align-items: center; + + font-size: var(--fz-200); + font-weight: 600; + + border-bottom: 1px solid var(--color-gray-90); + + padding-block-end: 0.7rem; + padding: 1rem; + + div { + display: flex; + justify-content: space-start; + align-items: center; + gap: 0.4rem; + + svg { + inline-size: 1.13rem; + block-size: 1.13rem; + } + } + } + + .note { + padding: 1rem; + + ul { + border-right: 2px dotted var(--color-gray-80); + + padding-inline-start: 0.8rem; + + font-size: var(--fz-200); + line-height: 1.5rem; + + li span { + font-weight: 600; + font-size: var(--fz-300); + } + } + } + + button { + display: flex; + justify-content: space-between; + align-items: center; + + background-color: var(--color-primary); + color: var(--color-primary-opposite); + + border-radius: var(--border-radius); + border: none; + + padding-block: 0.8rem; + padding-inline: 1rem; + + margin: 1rem; + + cursor: pointer; + + transition: 0.2s ease-in-out; + transition-property: background-color, color; + + &:hover { + background-color: var(--color-primary-darker); + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/info/info-card.component.tsx b/src/app/doctor/[[...slug]]/components/info/info-card.component.tsx new file mode 100644 index 0000000..64508ac --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/info/info-card.component.tsx @@ -0,0 +1,79 @@ +import { ReactElement } from "react"; + +import DoctorImageComponent from "@/components/common/doctor-image/doctor-image.component"; +import CardComponent from "@/components/card/card.component"; + +import MingcuteEye2Line from "@/icons/MingcuteEye2Line"; +import MingcuteBookmarkLine from "@/icons/MingcuteBookmarkLine"; +import MingcuteShare2Line from "@/icons/MingcuteShare2Line"; +import MingcutePencilLine from "@/icons/MingcutePencilLine"; + +import { DoctorModel } from "@/types/doctor.type"; + +import styles from "./info-card.module.css"; + +const view = 10; +const maxVote = 5; + +type Props = { doctorDetails: DoctorModel }; + +const InfoCardComponent: React.FC = ({ + doctorDetails, +}): ReactElement => { + return ( + +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + + {view.toLocaleString()}K + + +
+ +
+ + +
+

{doctorDetails.name}

+ شماره نظام پزشکی: 165017 +
+
+ +
+ {doctorDetails.brief} + + + + {doctorDetails?.averageRating?.toLocaleString()} از{" "} + {maxVote?.toLocaleString()} + +  رضایت ({doctorDetails?.totalVotes?.toLocaleString()} نظر) + +
+
+
+ ); +}; + +export default InfoCardComponent; diff --git a/src/app/doctor/[[...slug]]/components/info/info-card.module.css b/src/app/doctor/[[...slug]]/components/info/info-card.module.css new file mode 100644 index 0000000..70ddb2d --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/info/info-card.module.css @@ -0,0 +1,101 @@ +.card { + .action { + display: flex; + justify-content: space-between; + align-items: center; + + margin-block-end: 1rem; + + ul { + display: flex; + align-items: center; + gap: 0.5rem; + + font-size: var(--fz-300); + + li button { + display: flex; + align-items: center; + gap: 0.3rem; + + background-color: inherit; + + border: none; + + padding: 0.5rem; + + cursor: pointer; + } + } + + .review { + display: flex; + justify-content: space-end; + align-items: center; + gap: 0.2rem; + + svg { + inline-size: 1.3rem; + block-size: 1.3rem; + } + } + } + + .info { + display: flex; + align-items: center; + gap: 0.8rem; + + padding: 1rem; + + background-color: var(--color-surface-400); + border-radius: var(--border-radius); + + h1 { + font-size: var(--fz-500); + } + + span { + font-size: var(--fz-100); + display: inline-block; + margin-block-start: 1rem; + } + } + + .brief_container { + text-align: center; + margin-block: 1rem; + + .brief { + font-size: var(--fz-200); + font-weight: normal; + + color: var(--color-text-700); + + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + + padding-inline: 0.5rem; + padding-block: 0.2rem; + } + + .rate { + display: block; + + font-size: var(--fz-200); + + margin-block-start: 2rem; + + .ave_rate { + background-color: var(--color-success-darker); + + color: var(--color-primary-fade); + + padding-inline: 0.5rem; + padding-block: 0.1rem; + + border-radius: 0.5rem; + } + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/reserve/reserve.component.tsx b/src/app/doctor/[[...slug]]/components/reserve/reserve.component.tsx new file mode 100644 index 0000000..4d5a220 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reserve/reserve.component.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from "react"; + +import CardComponent from "@/components/card/card.component"; + +import { MingcuteArrowLeftLine } from "@/icons/MingcuteArrowLeftLine"; +import MingcuteWalkFill from "@/icons/MingcuteWalkFill"; + +import styles from "./reserve.module.css"; + +const ReserveComponent = (): ReactNode => { + return ( + +
+
+ + نوبت اینترنتی و مراجعه حضوری +
+ +
+
    +
  • امکان دریافت زودترین نوبت
  • +
+
+ + +
+
+ ); +}; + +export default ReserveComponent; diff --git a/src/app/doctor/[[...slug]]/components/reserve/reserve.module.css b/src/app/doctor/[[...slug]]/components/reserve/reserve.module.css new file mode 100644 index 0000000..e56238c --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reserve/reserve.module.css @@ -0,0 +1,66 @@ +.card { + padding: 0 !important; +} + +.container { + display: grid; + gap: 0.5rem; + + .title { + display: flex; + align-items: center; + gap: 0.3rem; + + font-size: var(--fz-200); + font-weight: 600; + + border-bottom: 1px solid var(--color-gray-90); + + padding-block-end: 0.7rem; + padding: 1rem; + + svg { + inline-size: 1.2rem; + block-size: 1.2rem; + } + } + + .note { + padding: 1rem; + + ul { + border-right: 2px dotted var(--color-gray-80); + + padding-inline-start: 0.8rem; + + font-size: var(--fz-200); + line-height: 1.5rem; + } + } + + button { + display: flex; + justify-content: space-between; + align-items: center; + + background-color: var(--color-primary); + color: var(--color-primary-opposite); + + border-radius: var(--border-radius); + border: none; + + padding-block: 0.8rem; + padding-inline: 1rem; + + margin: 1rem; + + cursor: pointer; + + transition: 0.2s ease-in-out; + transition-property: background-color, color; + + &:hover { + background-color: var(--color-primary-darker); + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/reviews/rating-progress.component.tsx b/src/app/doctor/[[...slug]]/components/reviews/rating-progress.component.tsx new file mode 100644 index 0000000..3cdc750 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/rating-progress.component.tsx @@ -0,0 +1,36 @@ +import { ReactElement } from "react"; + +import { RatingProgress } from "@/types/doctor.type"; + +import styles from "./rating-progress.module.css"; + +type Props = { + ratingProgress: RatingProgress[]; +}; + +const RatingProgressComponent: React.FC = ({ + ratingProgress, +}): ReactElement => { + return ( +
+ {ratingProgress.map((rating, index) => { + return ( +
+ {rating.lable} +
+
+
+
+ {rating.rate.toLocaleString()} +
+
+ ); + })} +
+ ); +}; + +export default RatingProgressComponent; diff --git a/src/app/doctor/[[...slug]]/components/reviews/rating-progress.module.css b/src/app/doctor/[[...slug]]/components/reviews/rating-progress.module.css new file mode 100644 index 0000000..1ad20e6 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/rating-progress.module.css @@ -0,0 +1,45 @@ +.progress_container { + inline-size: 100%; + + .progress_lable { + display: inline-block; + margin-block-start: 0.8rem; + } + + .progress_items { + display: flex; + align-items: center; + gap: 0.625rem; + + margin-block-start: 0.2rem; + + > div { + position: relative; + + inline-size: 100%; + + block-size: 0.5rem; + + background-color: var(--color-gray-90); + + border-radius: 0.5rem; + + overflow: hidden; + + .progress_fill { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + + block-size: 100%; + + background-color: var(--color-success-darker); + + transition: all; + transition-duration: 500; + + border-radius: 0.5rem; + } + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/reviews/review-item.component.tsx b/src/app/doctor/[[...slug]]/components/reviews/review-item.component.tsx new file mode 100644 index 0000000..8d2782e --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/review-item.component.tsx @@ -0,0 +1,72 @@ +import { ReactElement } from "react"; + +import MingcuteThumbUp2Line from "@/icons/MingcuteThumbUp2Line"; +import MingcuteShare2Line from "@/icons/MingcuteShare2Line"; +import MingcuteMore2Line from "@/icons/MingcuteMore2Line"; + +import { timeAgo } from "@/utils/timeAgo"; + +import { DoctorsReviews } from "@/types/doctor.type"; + +import styles from "./review-item.module.css"; + +type Props = { + review?: DoctorsReviews; +}; + +const ReviewItemComponent: React.FC = ({ review }): ReactElement => { + return ( +
+
+
+
+ س +
+ +
+
+ {review?.patientName} + {review?.isVisited && ( + ویزیت شده + )} +
+
+ {review?.date && ( + <> + {timeAgo(review?.date)} +  |  + + )} + + ویزیت آنلاین پذیرش24 +
+
+
+ +
+ {review?.vote.toLocaleString()} + +
+ +
+
+
+ +

{review?.text}

+ +
+ + + +
+
+ ); +}; + +export default ReviewItemComponent; diff --git a/src/app/doctor/[[...slug]]/components/reviews/review-item.module.css b/src/app/doctor/[[...slug]]/components/reviews/review-item.module.css new file mode 100644 index 0000000..dca86a1 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/review-item.module.css @@ -0,0 +1,139 @@ +.container { + > *:not(:first-child) { + margin-block-start: 1.5rem; + } + + .user_info { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + + .owner .image, + .rate_container .rate { + display: flex; + justify-content: center; + align-items: center; + + inline-size: 2.2rem; + block-size: 2.2rem; + + font-size: var(--fz-300); + + color: var(--color-gray-99); + } + + .rate_container { + display: flex; + justify-content: end; + align-items: center; + gap: 0.5rem; + + .more_btn { + display: flex; + justify-content: center; + align-items: center; + + cursor: pointer; + + inline-size: 2rem; + block-size: 2rem; + + svg { + inline-size: 1.3rem; + block-size: 1.3rem; + } + } + } + + .owner { + display: flex; + align-items: center; + gap: 0.5rem; + + .image { + background-color: var(--color-danger-darker); + + border-radius: 50%; + } + + .name_container { + .name { + font-size: var(--fz-200); + + span:first-of-type { + font-weight: 600; + } + + .visited_badge { + display: inline-block; + + color: var(--color-text-700); + + margin-inline-start: 0.5rem; + + font-size: var(--fz-100); + + background-color: var(--color-surface-400); + + padding-block: 0.1rem; + padding-inline: 0.3rem; + + border-radius: var(--border-radius); + } + } + + .date { + color: var(--color-text-700); + + font-size: var(--fz-100); + } + } + } + + .rate { + font-weight: 600; + + background-color: var(--color-primary-darker); + + border-top-left-radius: 0.35rem; + border-top-right-radius: 0.35rem; + border-bottom-left-radius: 0.35rem; + } + } + + .text { + font-size: var(--fz-200); + line-height: 1.7rem; + } + + .action_btn { + display: flex; + justify-content: end; + align-items: center; + gap: 0.5rem; + + > :not(:last-child) { + margin-inline-end: 1rem; + } + + button { + display: flex; + align-items: center; + gap: 0.4rem; + + background-color: inherit; + + border: none; + + font-size: var(--fz-200); + + cursor: pointer; + + svg { + inline-size: 1.2rem; + block-size: 1.2rem; + } + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/reviews/reviews-wrapper-component.tsx b/src/app/doctor/[[...slug]]/components/reviews/reviews-wrapper-component.tsx new file mode 100644 index 0000000..37ea6af --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/reviews-wrapper-component.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { PropsWithChildren, ReactElement } from "react"; + +import ReviewsFiltersProvider from "@/app/doctor/provider/reviews/reviews-filters.provider"; +import ReviewsProvider from "@/app/doctor/provider/reviews/reviews.provider"; +import VoteFiltersProvider from "@/app/doctor/provider/reviews/vote-filters.provider"; + +import { DoctorsReviews } from "@/types/doctor.type"; + +type Props = PropsWithChildren & { + doctorsReviews: DoctorsReviews[]; +}; + +const ReviewsWrapperComponent: React.FC = ({ + children, + doctorsReviews, +}): ReactElement => { + return ( + + + + {children} + + + + ); +}; + +export default ReviewsWrapperComponent; diff --git a/src/app/doctor/[[...slug]]/components/reviews/reviews.component.tsx b/src/app/doctor/[[...slug]]/components/reviews/reviews.component.tsx new file mode 100644 index 0000000..ae5ef77 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/reviews.component.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { ReactNode, useContext } from "react"; + +import { ReviewsContext } from "@/app/doctor/provider/reviews/reviews.provider"; + +import CardComponent from "@/components/card/card.component"; +import RatingProgressComponent from "./rating-progress.component"; +import ReviewItemComponent from "./review-item.component"; +import LoadMoreComponent from "@/components/common/buttons/load-more.component"; +import SortComponent from "../sort/sort.component"; +import VoteFilterComponent from "../vote-filter/vote-filter.component"; + +import { DoctorsReviews, RatingProgress } from "@/types/doctor.type"; + +import styles from "./reviews.module.css"; + +const maxVote = 5; + +type Props = { + doctorName: string; + averageRating: number; + totalVotes: number; + doctorsReviews: DoctorsReviews[]; + ratingProgress: RatingProgress[]; +}; + +const ReviewsComponent: React.FC = ({ + doctorName, + averageRating, + totalVotes, + doctorsReviews, + ratingProgress, +}): ReactNode => { + const { filteredReviews } = useContext(ReviewsContext); + + return ( +
+
+

+ نظرات در مورد دکتر +   + {doctorName} +

+
+ + +
+ + {averageRating.toLocaleString()} از {maxVote?.toLocaleString()} + +  رضایت ({totalVotes.toLocaleString()} نظر) +
+ +
+ +
+
+ + {doctorsReviews?.length !== 0 && ( + +
+ + +
+ {filteredReviews?.map((review: DoctorsReviews, index: number) => ( + + ))} + + +
+ )} +
+ ); +}; + +export default ReviewsComponent; diff --git a/src/app/doctor/[[...slug]]/components/reviews/reviews.module.css b/src/app/doctor/[[...slug]]/components/reviews/reviews.module.css new file mode 100644 index 0000000..7ba1307 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/reviews/reviews.module.css @@ -0,0 +1,47 @@ +.container { + display: grid; + gap: 1rem; + + .title { + h2 { + font-size: var(--fz-300); + } + } + + .rate { + display: block; + text-align: center; + margin-block: 1rem; + + font-size: var(--fz-200); + + .ave_rate { + background-color: var(--color-success-darker); + + color: var(--color-primary-fade); + + padding-inline: 0.5rem; + padding-block: 0.1rem; + + border-radius: 0.5rem; + } + } + + .card > *:not(:first-child):not(:last-child) { + margin-block-start: 2rem; + + padding-block-start: 2rem; + + border-top: 1px solid var(--color-gray-90); + } + + .card { + padding-block: 1.5rem; + } + + .toolbar { + display: flex; + align-items: center; + gap: 1rem; + } +} diff --git a/src/app/doctor/[[...slug]]/components/services/services.component.tsx b/src/app/doctor/[[...slug]]/components/services/services.component.tsx new file mode 100644 index 0000000..9dc9b68 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/services/services.component.tsx @@ -0,0 +1,40 @@ +import { ReactElement } from "react"; + +import CardComponent from "@/components/card/card.component"; +import { MingcuteAddCircleFill } from "@/icons/MingcuteAddCircleFill"; + +import styles from "./services.module.css"; + +type Props = { + doctorName: string; + services: string[]; +}; + +const ServicesComponent: React.FC = ({ + doctorName, + services, +}): ReactElement => { + return ( +
+
+

خدمات دکتر {doctorName}

+
+ +
+
    + {services?.map((service, index) => { + return ( +
  • + + {service} +
  • + ); + })} +
+
+
+
+ ); +}; + +export default ServicesComponent; diff --git a/src/app/doctor/[[...slug]]/components/services/services.module.css b/src/app/doctor/[[...slug]]/components/services/services.module.css new file mode 100644 index 0000000..e20c067 --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/services/services.module.css @@ -0,0 +1,41 @@ +.services { + display: grid; + gap: 1rem; + + .title { + h2 { + font-size: var(--fz-300); + } + } + + .services-item { + ul { + border-right: 2px dotted var(--color-gray-80); + + padding-inline-start: 0.75rem; + + font-size: var(--fz-200); + line-height: 1.5rem; + + li { + display: flex; + align-items: center; + gap: 0.5rem; + + svg { + inline-size: 1rem; + block-size: 1rem; + } + } + + li span { + font-weight: 600; + font-size: var(--fz-300); + } + } + + ul li:not(:first-child) { + margin-block-start: 0.5rem; + } + } +} diff --git a/src/app/doctor/[[...slug]]/components/sort/sort.component.tsx b/src/app/doctor/[[...slug]]/components/sort/sort.component.tsx new file mode 100644 index 0000000..f7a589b --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/sort/sort.component.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { ReactElement, useContext } from "react"; + +import { ReviewsFiltersContext } from "@/app/doctor/provider/reviews/reviews-filters.provider"; + +import SelectComponent from "@/components/select/select.component"; + +import { SelectOptionType } from "@/types/select-option.type"; + +const options: SelectOptionType[] = [ + { id: 1, value: "all", label: "همه" }, + { id: 2, value: "visited", label: "ویزیت شده" }, + { id: 3, value: "noVisited", label: "ویزیت نشده" }, +]; + +export default function SortComponent(): ReactElement { + const { filters, dispatchFilters } = useContext(ReviewsFiltersContext); + + const handleChange = (option: SelectOptionType) => { + dispatchFilters({ + type: "SET_FILTER", + payload: option.value as "all" | "visited" | "noVisited", + }); + }; + + return ( + opt.value === filters.selectedFilter) || + options[0] + } + onSelectedOptionChange={handleChange} + /> + ); +} diff --git a/src/app/doctor/[[...slug]]/components/vote-filter/vote-filter.component.tsx b/src/app/doctor/[[...slug]]/components/vote-filter/vote-filter.component.tsx new file mode 100644 index 0000000..0725bdd --- /dev/null +++ b/src/app/doctor/[[...slug]]/components/vote-filter/vote-filter.component.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ReactElement, useContext } from "react"; + +import { VoteFiltersContext } from "@/app/doctor/provider/reviews/vote-filters.provider"; + +import SelectComponent from "@/components/select/select.component"; + +import { SelectOptionType } from "@/types/select-option.type"; + +const options: SelectOptionType[] = [ + { id: 1, value: "all", label: "همه" }, + { id: 2, value: "best", label: "امتیاز ۵" }, + { id: 3, value: "middle", label: "بین ۳ تا ۵ امتیاز" }, + { id: 4, value: "bad", label: "کمتر از ۳ امتیاز" }, +]; + +export default function VoteFilterComponent(): ReactElement { + const { filters, dispatchFilters } = useContext(VoteFiltersContext); + + const handleChange = (option: SelectOptionType) => { + dispatchFilters({ + type: "SET_VOTE_FILTER", + payload: option.value as "all" | "best" | "middle" | "bad", + }); + }; + + return ( + opt.value === filters.selectedFilter) || + options[0] + } + onSelectedOptionChange={handleChange} + /> + ); +} diff --git a/src/app/doctor/[[...slug]]/page.module.css b/src/app/doctor/[[...slug]]/page.module.css new file mode 100644 index 0000000..7b6b705 --- /dev/null +++ b/src/app/doctor/[[...slug]]/page.module.css @@ -0,0 +1,32 @@ +.page { + display: grid; + grid-template-areas: "detaile reserve"; + grid-template-columns: 2fr 1fr; + align-items: start; + gap: 1rem; + + margin-block: 2rem; + + .detaile { + grid-area: detaile; + + display: grid; + gap: 1rem; + } + + .reserve { + grid-area: reserve; + + display: grid; + gap: 1rem; + } +} + +@media screen and (max-width: 1100px) { + .page { + grid-template-areas: + "detaile" + "reserve"; + grid-template-columns: 1fr; + } +} diff --git a/src/app/doctor/[[...slug]]/page.tsx b/src/app/doctor/[[...slug]]/page.tsx new file mode 100644 index 0000000..79a15fa --- /dev/null +++ b/src/app/doctor/[[...slug]]/page.tsx @@ -0,0 +1,109 @@ +import { ReactElement } from "react"; + +import { notFound } from "next/navigation"; + +import AboutComponent from "./components/about/about.component"; +import ActivitiesComponent from "./components/activities/activities.component"; +import CharacteristicsComponent from "./components/characteristics/characteristics.component"; +import ContactComponent from "./components/contact/contact.component"; +import CounsulationComponent from "./components/counsulation/counsulation.component"; +import InfoCardComponent from "./components/info/info-card.component"; +import ReserveComponent from "./components/reserve/reserve.component"; +import ReviewsWrapperComponent from "./components/reviews/reviews-wrapper-component"; +import ReviewsComponent from "./components/reviews/reviews.component"; +import ServicesComponent from "./components/services/services.component"; + +import { doctorsData } from "@/models/doctors"; +import { DoctorModel } from "@/types/doctor.type"; + +import styles from "./page.module.css"; + +type Props = { + params: { + slug: string; + }; +}; + +const DoctorPage: React.FC = ({ params }): ReactElement => { + let slug = ""; + if (!Array.isArray(params.slug)) return notFound(); + if (params.slug.length === 1) { + slug = params.slug[0]; + } else if (params.slug.length === 2) { + slug = params.slug[1]; + } else return notFound(); + + const doctorDetails = doctorsData.filter( + (doctor: DoctorModel) => slug === doctor.slug, + )[0]; + + if (!doctorDetails) return notFound(); + + return ( +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +}; + +export default DoctorPage; diff --git a/src/app/doctor/provider/reviews/reviews-filters.provider.tsx b/src/app/doctor/provider/reviews/reviews-filters.provider.tsx new file mode 100644 index 0000000..ffe9142 --- /dev/null +++ b/src/app/doctor/provider/reviews/reviews-filters.provider.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + createContext, + Dispatch, + PropsWithChildren, + ReactElement, + useReducer, +} from "react"; + +type FilterState = { + selectedFilter: "all" | "visited" | "noVisited"; +}; + +type FilterAction = { + type: "SET_FILTER"; + payload: "all" | "visited" | "noVisited"; +}; + +const reviewsFiltersReducer = ( + state: FilterState, + action: FilterAction, +): FilterState => { + switch (action.type) { + case "SET_FILTER": + return { ...state, selectedFilter: action.payload }; + default: + return state; + } +}; + +type ContextValue = { + filters: FilterState; + dispatchFilters: Dispatch; +}; + +export const ReviewsFiltersContext = createContext({ + filters: { selectedFilter: "all" }, + dispatchFilters: () => {}, +}); + +type Props = PropsWithChildren & { + defaultFilter?: FilterState; +}; + +export default function ReviewsFiltersProvider({ + children, + defaultFilter = { selectedFilter: "all" }, +}: Props): ReactElement { + const [filters, dispatchFilters] = useReducer( + reviewsFiltersReducer, + defaultFilter, + ); + + return ( + + {children} + + ); +} diff --git a/src/app/doctor/provider/reviews/reviews.provider.tsx b/src/app/doctor/provider/reviews/reviews.provider.tsx new file mode 100644 index 0000000..e677cd6 --- /dev/null +++ b/src/app/doctor/provider/reviews/reviews.provider.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { + createContext, + PropsWithChildren, + ReactElement, + useContext, + useEffect, + useState, +} from "react"; + +import { ReviewsFiltersContext } from "./reviews-filters.provider"; +import { VoteFiltersContext } from "./vote-filters.provider"; + +import { DoctorsReviews } from "@/types/doctor.type"; + +type ContextValue = { + filteredReviews: DoctorsReviews[]; +}; + +export const ReviewsContext = createContext({ + filteredReviews: [], +}); + +type Props = PropsWithChildren & { + doctorsReviews: DoctorsReviews[]; +}; + +export default function ReviewsProvider({ + children, + doctorsReviews, +}: Props): ReactElement { + const { filters: visitFilters } = useContext(ReviewsFiltersContext); + const { filters: voteFilters } = useContext(VoteFiltersContext); + + const [filteredReviews, setFilteredReviews] = + useState(doctorsReviews); + + useEffect(() => { + let filtered = doctorsReviews; + + if (visitFilters.selectedFilter === "visited") { + filtered = filtered.filter((review) => review.isVisited); + } else if (visitFilters.selectedFilter === "noVisited") { + filtered = filtered.filter((review) => !review.isVisited); + } + + if (voteFilters.selectedFilter === "best") { + filtered = filtered.filter((review) => review.vote === 5); + } else if (voteFilters.selectedFilter === "middle") { + filtered = filtered.filter( + (review) => review.vote >= 3 && review.vote < 5, + ); + } else if (voteFilters.selectedFilter === "bad") { + filtered = filtered.filter((review) => review.vote < 3); + } + + setFilteredReviews(filtered); + }, [visitFilters, voteFilters, doctorsReviews]); + + return ( + + {children} + + ); +} diff --git a/src/app/doctor/provider/reviews/vote-filters.provider.tsx b/src/app/doctor/provider/reviews/vote-filters.provider.tsx new file mode 100644 index 0000000..bbf90a1 --- /dev/null +++ b/src/app/doctor/provider/reviews/vote-filters.provider.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + createContext, + Dispatch, + PropsWithChildren, + ReactElement, + useReducer, +} from "react"; + +type VoteFilterState = { + selectedFilter: "all" | "best" | "middle" | "bad"; +}; + +type VoteFilterAction = { + type: "SET_VOTE_FILTER"; + payload: "all" | "best" | "middle" | "bad"; +}; + +const voteFiltersReducer = ( + state: VoteFilterState, + action: VoteFilterAction, +): VoteFilterState => { + switch (action.type) { + case "SET_VOTE_FILTER": + return { ...state, selectedFilter: action.payload }; + default: + return state; + } +}; + +type ContextValue = { + filters: VoteFilterState; + dispatchFilters: Dispatch; +}; + +export const VoteFiltersContext = createContext({ + filters: { selectedFilter: "all" }, + dispatchFilters: () => {}, +}); + +type Props = PropsWithChildren & { + defaultFilter?: VoteFilterState; +}; + +export default function VoteFiltersProvider({ + children, + defaultFilter = { selectedFilter: "all" }, +}: Props): ReactElement { + const [filters, dispatchFilters] = useReducer( + voteFiltersReducer, + defaultFilter, + ); + + return ( + + {children} + + ); +} diff --git a/src/app/doctors/[[...slug]]/components/filter/clear-all-filter.module.css b/src/app/doctors/[[...slug]]/components/filter/clear-all-filter.module.css index 7b5a487..22476fa 100644 --- a/src/app/doctors/[[...slug]]/components/filter/clear-all-filter.module.css +++ b/src/app/doctors/[[...slug]]/components/filter/clear-all-filter.module.css @@ -1,11 +1,15 @@ .clear { - width: 100%; + inline-size: 100%; + border-radius: var(--border-radius); background-color: var(--color-danger); + color: var(--color-primary-opposite); cursor: pointer; + padding-block: 0.4rem; + &:hover { background-color: var(--color-danger-darker); } diff --git a/src/app/doctors/[[...slug]]/components/item/item.component.tsx b/src/app/doctors/[[...slug]]/components/item/item.component.tsx index ab85083..fcfaef2 100644 --- a/src/app/doctors/[[...slug]]/components/item/item.component.tsx +++ b/src/app/doctors/[[...slug]]/components/item/item.component.tsx @@ -1,18 +1,14 @@ import { ReactElement } from "react"; -import Image from "next/image"; +import DoctorImageComponent from "@/components/common/doctor-image/doctor-image.component"; import { MingcuteStarFill } from "@/icons/MingcuteStarFill"; +import { MingcuteLocationFill } from "@/icons/MingcuteLocationFill"; +import { MingcuteArrowLeftLine } from "@/icons/MingcuteArrowLeftLine"; import { DoctorModel } from "@/types/doctor.type"; -import { GenderEnums } from "@/enums/gender"; - -import maleImg from "@/assets/fallback-images/portrait-3d-male-doctor.jpg"; -import femaleImg from "@/assets/fallback-images/portrait-3d-female-doctor.jpg"; import styles from "./item.module.css"; -import { MingcuteLocationFill } from "@/icons/MingcuteLocationFill"; -import { MingcuteArrowLeftLine } from "@/icons/MingcuteArrowLeftLine"; type Props = { item: DoctorModel; @@ -22,16 +18,8 @@ const ItemComponent = ({ item }: Props): ReactElement => { return (
  • - {item.name} + +

    {item.name}

    {item.brief}

    diff --git a/src/app/doctors/[[...slug]]/components/item/item.module.css b/src/app/doctors/[[...slug]]/components/item/item.module.css index 997af0f..1e7fc9c 100644 --- a/src/app/doctors/[[...slug]]/components/item/item.module.css +++ b/src/app/doctors/[[...slug]]/components/item/item.module.css @@ -2,30 +2,25 @@ position: relative; padding: 1rem; - height: auto; + block-size: auto; - background-color: var(--color-gray-20); + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); - border-radius: 0.5rem; + border-radius: var(--border-radius); .info { display: flex; - justify-content: start; align-items: start; gap: 0.8rem; } - .image { - border-radius: 0.7rem; - border: 0.24rem solid var(--color-gray-98); - } - .name { font-size: var(--fz-400); } .brief { - margin-top: 0.2rem; + margin-block-start: 0.2rem; font-size: var(--fz-200); } @@ -34,39 +29,38 @@ align-items: center; gap: 0.15rem; - background-color: var(--color-gray-30); + background-color: var(--color-surface-300); - width: fit-content; + inline-size: fit-content; padding-block: 0.1rem; padding-inline: 0.2rem; - margin-top: 0.4rem; + margin-block-start: 0.4rem; border-radius: 0.2rem; font-size: var(--fz-100); .star { - color: var(--color-warning); - margin-top: 2px; + color: var(--color-star); + margin-block-start: 2px; } } .badge { display: flex; - justify-content: start; gap: 0.4rem; margin-block-start: 2rem; .badge_item { - background-color: var(--color-gray-40); - width: fit-content; + background-color: var(--color-surface-400); + inline-size: fit-content; font-size: var(--fz-100); - padding-block: 0.1rem; - padding-inline: 0.2rem; - border-radius: 0.2rem; + padding-block: 0.3rem; + padding-inline: 0.4rem; + border-radius: var(--border-radius); } } @@ -74,6 +68,8 @@ margin-block-start: 1.5rem; address { + color: var(--color-gray-40); + font-style: normal; font-size: var(--fz-200); @@ -83,7 +79,7 @@ svg { flex-shrink: 0; - margin-top: 0.25rem; + margin-block-start: 0.25rem; } } } @@ -97,19 +93,22 @@ align-items: center; gap: 0.15rem; + font-size: var(--fz-200); + background-color: var(--color-primary); + color: var(--color-primary-opposite); border: none; - border-radius: 0.2rem; + border-radius: var(--border-radius); - padding: 0.3rem; + padding: 0.5rem; cursor: pointer; transition: 0.2s ease-in-out; transition-property: background-color, color; &:hover { - background-color: var(--color-primary-fade); + background-color: var(--color-primary-darker); color: var(--color-primary-opposite); } } diff --git a/src/app/doctors/[[...slug]]/components/list/list.component.tsx b/src/app/doctors/[[...slug]]/components/list/list.component.tsx index 78ac2a2..2c479dc 100644 --- a/src/app/doctors/[[...slug]]/components/list/list.component.tsx +++ b/src/app/doctors/[[...slug]]/components/list/list.component.tsx @@ -19,7 +19,10 @@ const ListComponent = ({ return (
      {doctors.map((item: DoctorModel) => ( - + ))} diff --git a/src/app/globals.css b/src/app/globals.css index 4606fa3..f5d2954 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,29 +1,53 @@ :root { - --color-gray-98: hsl(0deg 0% 98%); - --color-gray-90: hsl(0deg 0% 90%); - --color-gray-80: hsl(0deg 0% 80%); - --color-gray-70: hsl(0deg 0% 70%); - --color-gray-40: hsl(0deg 0% 40%); - --color-gray-30: hsl(0deg 0% 30%); - --color-gray-20: hsl(0deg 0% 20%); - --color-gray-16: hsl(0deg 0% 16%); - --color-gray-12: hsl(0deg 0% 12%); - --color-gray-10: hsl(0deg 0% 10%); - - --color-default-background: var(--color-gray-10); - --color-default-foreground: var(--color-gray-98); - - --color-primary: hsl(120deg 50% 48%); - --color-primary-fade: hsl(120deg 50% 96%); - --color-primary-lighter: hsl(120deg 50% 52%); - --color-primary-darker: hsl(120deg 50% 44%); - --color-primary-opposite: var(--color-gray-10); - - --color-danger: hsl(0, 47.5%, 50%); - --color-danger-darker: hsl(360, 61.5%, 42.5%); - --color-warning: hsl(57, 100%, 50%); - - --border-radius: 0.875rem; + --color-gray-99: hsl(200deg 10% 99%); + --color-gray-97: hsl(200deg 10% 97%); + --color-gray-93: hsl(200deg 10% 93%); + --color-gray-90: hsl(200deg 10% 93%); + --color-gray-80: hsl(200deg 10% 80%); + --color-gray-70: hsl(200deg 10% 70%); + --color-gray-40: hsl(200deg 10% 40%); + --color-gray-30: hsl(200deg 10% 30%); + --color-gray-20: hsl(200deg 10% 20%); + --color-gray-16: hsl(200deg 10% 16%); + --color-gray-12: hsl(200deg 10% 12%); + --color-gray-10: hsl(200deg 10% 10%); + + --color-surface-300: var(--color-gray-93); + --color-surface-400: var(--color-gray-97); + --color-surface-700: var(--color-gray-99); + + --color-text-400: var(--color-gray-10); + --color-text-700: var(--color-gray-40); + + --color-primary: hsl(200deg 90% 46%); + --color-primary-fade: hsl(200deg 90% 96%); + --color-primary-lighter: hsl(200deg 90% 50%); + --color-primary-darker: hsl(200deg 90% 42%); + --color-primary-opposite: var(--color-gray-99); + + --color-danger: hsl(10deg 80% 48%); + --color-danger-fade: hsl(10deg 80% 96%); + --color-danger-lighter: hsl(10deg 80% 52%); + --color-danger-darker: hsl(10deg 80% 44%); + --color-danger-opposite: var(--color-gray-99); + + --color-success: hsl(110, 100%, 50%); + --color-success-darker: hsl(110, 88%, 34%); + + --color-star: hsl(50deg 100% 50%); + + --color-border: var(--color-gray-80); + + --shadow-300: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-400: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-500: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-700: 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-800: 0 20px 25px -5px rgb(0 0 0 / 0.1), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-900: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --border-radius: 0.75rem; --full-width: 75rem; --full-width-padding-inline: calc(max(100% - var(--full-width), 2rem) / 2); @@ -43,7 +67,10 @@ } html { - color-scheme: dark; + color-scheme: light; + font-family: var(--font-vazirmatn), system-ui, sans-serif; + accent-color: var(--color-primary); + caret-color: var(--color-primary); } input, @@ -65,7 +92,7 @@ ul { } p { - color: var(--color-gray-80); + color: var(--color-text-700); } a { @@ -74,8 +101,8 @@ a { } body { - background-color: var(--color-default-background); - color: var(--color-default-foreground); + background-color: var(--color-surface-400); + color: var(--color-text-400); display: grid; grid-template-rows: auto 1fr auto; @@ -90,7 +117,7 @@ body { } .tagline { - background-color: var(--color-gray-16); + background-color: var(--color-surface-300); padding-block: 1rem; text-align: center; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fb00c97..bf70ef8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import { ReactElement } from "react"; import type { Metadata } from "next"; -import { Vazirmatn } from "next/font/google"; +import localFont from "next/font/local"; import FooterComponent from "@/components/footer/footer.component"; import HeaderComponent from "@/components/header/header.component"; @@ -9,23 +9,69 @@ import HeaderComponent from "@/components/header/header.component"; import "@/styles/typography.css"; import "./globals.css"; -const vazirmatn = Vazirmatn({ - subsets: ["latin", "arabic"], - display: "swap", -}); - export const metadata: Metadata = { title: "دکتر من", description: "پلتفرم جامع جستجوی دکتر و رزرو نوبت آنلاین", }; +const vazirmatn_FD = localFont({ + src: [ + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Thin.woff2", + weight: "100", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-ExtraLight.woff2", + weight: "200", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Light.woff2", + weight: "300", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Regular.woff2", + weight: "400", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Medium.woff2", + weight: "500", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-SemiBold.woff2", + weight: "600", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Bold.woff2", + weight: "700", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-ExtraBold.woff2", + weight: "800", + style: "normal", + }, + { + path: "../public/fonts/vazirmatn/Vazirmatn-FD-Black.woff2", + weight: "900", + style: "normal", + }, + ], + variable: "--font-vazirmatn", +}); + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>): ReactElement { return ( - +
      {children}
      diff --git a/src/app/page.module.css b/src/app/page.module.css index 8dd4f0a..474f687 100644 --- a/src/app/page.module.css +++ b/src/app/page.module.css @@ -34,7 +34,8 @@ gap: 1rem; > li { - background-color: var(--color-gray-20); + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); padding: 0.25rem 0.75rem; diff --git a/src/assets/logo/certificate.svg b/src/assets/logo/certificate.svg index 0876d71..bc573bd 100644 --- a/src/assets/logo/certificate.svg +++ b/src/assets/logo/certificate.svg @@ -4,7 +4,7 @@ diff --git a/src/assets/logo/enamad.svg b/src/assets/logo/enamad.svg index 732c7d3..dad796e 100644 --- a/src/assets/logo/enamad.svg +++ b/src/assets/logo/enamad.svg @@ -5,11 +5,11 @@ diff --git a/src/assets/logo/idk.svg b/src/assets/logo/idk.svg index f74858d..ddf5947 100644 --- a/src/assets/logo/idk.svg +++ b/src/assets/logo/idk.svg @@ -5,17 +5,17 @@ - + diff --git a/src/components/card/card.component.tsx b/src/components/card/card.component.tsx index 8df8f0b..9113b0d 100644 --- a/src/components/card/card.component.tsx +++ b/src/components/card/card.component.tsx @@ -1,11 +1,14 @@ import { PropsWithChildren, ReactElement } from "react"; import styles from "@/components/card/card.module.css"; +import clsx from "clsx"; -type Props = PropsWithChildren; +type Props = PropsWithChildren & { + customStyle?: string; +}; -const CardComponent = ({ children }: Props): ReactElement => { - return
      {children}
      ; +const CardComponent = ({ children, customStyle }: Props): ReactElement => { + return
      {children}
      ; }; export default CardComponent; diff --git a/src/components/card/card.module.css b/src/components/card/card.module.css index d74b286..87e98ba 100644 --- a/src/components/card/card.module.css +++ b/src/components/card/card.module.css @@ -1,5 +1,6 @@ .card { - background-color: var(--color-gray-16); + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); padding: 1rem; diff --git a/src/components/common/buttons/load-more.component.tsx b/src/components/common/buttons/load-more.component.tsx new file mode 100644 index 0000000..7a61c31 --- /dev/null +++ b/src/components/common/buttons/load-more.component.tsx @@ -0,0 +1,9 @@ +import { ReactElement } from "react"; + +import styles from "./load-more.module.css"; + +const LoadMoreComponent = (): ReactElement => { + return ; +}; + +export default LoadMoreComponent; diff --git a/src/components/common/buttons/load-more.module.css b/src/components/common/buttons/load-more.module.css new file mode 100644 index 0000000..e5ab878 --- /dev/null +++ b/src/components/common/buttons/load-more.module.css @@ -0,0 +1,26 @@ +.btn { + inline-size: 100%; + + background-color: inherit; + + color: var(--color-primary); + font-size: var(--fz-300); + font-weight: 600; + + border: 1px solid var(--color-primary); + + border-radius: var(--border-radius); + + margin-block-start: 2rem; + + padding-block: 0.8rem; + + cursor: pointer; + + transition: 0.2s ease-in-out; + transition-property: background-color, color; + + &:hover { + background-color: var(--color-primary-fade); + } +} diff --git a/src/components/common/doctor-image/doctor-image.component.tsx b/src/components/common/doctor-image/doctor-image.component.tsx new file mode 100644 index 0000000..9371081 --- /dev/null +++ b/src/components/common/doctor-image/doctor-image.component.tsx @@ -0,0 +1,35 @@ +import { ReactElement } from "react"; + +import Image from "next/image"; + +import { GenderEnums } from "@/enums/gender"; +import { Gender } from "@/types/doctor.type"; + +import femaleImg from "@/assets/fallback-images/portrait-3d-female-doctor.jpg"; +import maleImg from "@/assets/fallback-images/portrait-3d-male-doctor.jpg"; + +import styles from "./doctor-image.module.css"; + +type Props = { + name: string; + image: string; + gender: Gender; +}; + +const DoctorImageComponent: React.FC = ({ + name, + image, + gender, +}): ReactElement => { + return ( + {name} + ); +}; + +export default DoctorImageComponent; diff --git a/src/components/common/doctor-image/doctor-image.module.css b/src/components/common/doctor-image/doctor-image.module.css new file mode 100644 index 0000000..cff69bd --- /dev/null +++ b/src/components/common/doctor-image/doctor-image.module.css @@ -0,0 +1,6 @@ +.image { + border-radius: 0.7rem; + border: 0.2rem solid var(--color-border); + + object-fit: cover; +} diff --git a/src/components/filter-button/filter-button.module.css b/src/components/filter-button/filter-button.module.css index 071931e..a823dd8 100644 --- a/src/components/filter-button/filter-button.module.css +++ b/src/components/filter-button/filter-button.module.css @@ -1,13 +1,14 @@ .filter-button { background-color: transparent; - color: inherit; + /* color: inherit; */ + color: var(--color-text-700); display: grid; place-content: center; padding: 0.5rem 1rem; - border: 1px solid currentcolor; + border: 1px solid var(--color-border); border-radius: var(--border-radius); transition: 0.2s ease-in-out; @@ -16,7 +17,7 @@ cursor: pointer; &:hover { - background-color: var(--color-primary-fade); + background-color: var(--color-primary-lighter); color: var(--color-primary-opposite); } diff --git a/src/components/footer/footer.module.css b/src/components/footer/footer.module.css index addd0f2..0b3d614 100644 --- a/src/components/footer/footer.module.css +++ b/src/components/footer/footer.module.css @@ -1,5 +1,5 @@ .footer { - background-color: var(--color-gray-12); + background-color: var(--color-surface-400); display: grid; grid-template-areas: "writings visuals" "copy copy"; @@ -37,7 +37,8 @@ > li { a { - font-size: var(--fz-500); + color: var(--color-text-700); + font-size: 1.5em; } } } diff --git a/src/components/global-search-box/global-search-box.module.css b/src/components/global-search-box/global-search-box.module.css index 7122154..a05e99b 100644 --- a/src/components/global-search-box/global-search-box.module.css +++ b/src/components/global-search-box/global-search-box.module.css @@ -1,4 +1,6 @@ .global-search-box { + background-color: var(--color-surface-700); + display: flex; align-items: center; gap: 0.5rem; @@ -7,14 +9,14 @@ padding-inline: 1rem; - border: 1px solid var(--color-gray-20); + border: 1px solid var(--color-border); border-radius: 999rem; .prefix { display: grid; align-items: center; - font-size: var(--fz-500); + font-size: 1.5em; } input { @@ -34,7 +36,7 @@ } .divider { - background-color: var(--color-gray-20); + background-color: var(--color-border); block-size: 2em; inline-size: 1px; @@ -60,7 +62,7 @@ } &:hover { - background-color: var(--color-gray-16); + background-color: var(--color-surface-300); } } } diff --git a/src/components/header/header.module.css b/src/components/header/header.module.css index b0bf77d..1b0283c 100644 --- a/src/components/header/header.module.css +++ b/src/components/header/header.module.css @@ -1,4 +1,7 @@ .header { + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); + display: flex; align-items: center; gap: 2rem; diff --git a/src/components/select/select.component.tsx b/src/components/select/select.component.tsx new file mode 100644 index 0000000..ee4ec12 --- /dev/null +++ b/src/components/select/select.component.tsx @@ -0,0 +1,175 @@ +import { + ReactElement, + useCallback, + useEffect, + useRef, + useState, + MouseEvent, + useMemo, +} from "react"; + +import clsx from "clsx"; + +import { SelectOptionType } from "@/types/select-option.type"; + +import styles from "./select.module.css"; + +type Props = { + floating?: boolean; + title?: string; + placeholder?: string; + options: SelectOptionType[]; + selectedOption?: SelectOptionType; + onSelectedOptionChange?: (value: SelectOptionType) => void; + onIsOpenChange?: (value: boolean) => void; +}; + +export default function SelectComponent({ + floating, + title, + placeholder, + options, + selectedOption, + onSelectedOptionChange, + onIsOpenChange, +}: Props): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + + const containerRef = useRef(null); + + const maximumCharactersCount = useMemo(() => { + return Math.max( + placeholder?.length ?? 0, + ...options.map((option) => option.label.length), + ); + }, [placeholder, options]); + + const selectOption = useCallback( + (option: SelectOptionType): void => { + if (option !== selectedOption) { + onSelectedOptionChange?.(option); + } + }, + [onSelectedOptionChange, selectedOption], + ); + + const optionClickHandler = ( + e: MouseEvent, + option: SelectOptionType, + ): void => { + e.stopPropagation(); + + selectOption(option); + setIsOpen(false); + }; + + useEffect(() => { + if (isOpen) { + setHighlightedIndex(0); + } + + onIsOpenChange?.(isOpen); + }, [isOpen, onIsOpenChange]); + + useEffect(() => { + const containerElement = containerRef.current; + + if (!containerElement) { + return; + } + + const keydownHandler = (e: KeyboardEvent): void => { + if (e.target != containerRef.current) { + return; + } + + switch (e.code) { + case "Enter": + case "Space": { + e.preventDefault(); + + if (isOpen) { + selectOption(options[highlightedIndex]); + } + + setIsOpen((prev) => !prev); + + break; + } + case "ArrowUp": + case "ArrowDown": { + e.preventDefault(); + + if (!isOpen) { + setIsOpen(true); + break; + } + + const newValue = highlightedIndex + (e.code === "ArrowDown" ? 1 : -1); + if (newValue >= 0 && newValue < options.length) { + setHighlightedIndex(newValue); + } + + break; + } + case "Escape": { + e.preventDefault(); + + setIsOpen(false); + break; + } + } + }; + + containerElement.addEventListener("keydown", keydownHandler); + + return () => { + containerElement.removeEventListener("keydown", keydownHandler); + }; + }, [isOpen, highlightedIndex, options, selectOption]); + + return ( +
      setIsOpen(false)} + onClick={() => setIsOpen((old) => !old)} + tabIndex={0} + className={clsx( + styles.container, + isOpen && styles.open, + floating && styles.floating, + )} + > + {title && {title}: } + + + {selectedOption?.label ?? placeholder ?? String.fromCharCode(160)} + + +
      + +
        + {options.map((option, index) => ( +
      • setHighlightedIndex(index)} + onClick={(e) => optionClickHandler(e, option)} + > + {option.label} +
      • + ))} +
      +
      + ); +} diff --git a/src/components/select/select.module.css b/src/components/select/select.module.css new file mode 100644 index 0000000..e6d6161 --- /dev/null +++ b/src/components/select/select.module.css @@ -0,0 +1,89 @@ +.container { + position: relative; + + display: flex; + align-items: center; + gap: 0.5em; + + padding: 0.5rem; + + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + + outline: none; + + cursor: default; + + .title { + font-size: var(--fz-300); + font-weight: 700; + } + + .value { + flex: 1 1 0; + } + + .caret { + border: 0.25em solid transparent; + border-block-start-color: var(--color-border); + + transform: translateY(25%); + } + + .options { + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); + + position: absolute; + inset-block-start: calc(100% + 0.5rem); + inset-inline-start: 0; + overflow-y: auto; + z-index: 100; + + max-block-size: 15rem; + min-inline-size: 100%; + inline-size: max-content; + + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + + .option { + padding: 0.25rem 0.5rem; + + cursor: pointer; + + &.highlighted { + background-color: var(--color-surface-300); + } + + &.selected { + background-color: var(--color-primary); + color: var(--color-primary-opposite); + } + } + } + + &:not(&.open) { + .options { + display: none; + } + } + + &:focus, + &.open { + border-color: var(--color-primary); + + .caret { + border-block-start-color: var(--color-primary); + } + } + + &.floating { + background-color: var(--color-surface-700); + box-shadow: var(--shadow-400); + + &:not(&:focus, &.open) { + border-color: transparent; + } + } +} diff --git a/src/icons/MingcuteAIFill.tsx b/src/icons/MingcuteAIFill.tsx new file mode 100644 index 0000000..c8be0b2 --- /dev/null +++ b/src/icons/MingcuteAIFill.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from "react"; + +export function MingcuteAIFill(props: SVGProps) { + return ( + + + + + + + ); +} diff --git a/src/icons/MingcuteAddCircleFill.tsx b/src/icons/MingcuteAddCircleFill.tsx new file mode 100644 index 0000000..57484ed --- /dev/null +++ b/src/icons/MingcuteAddCircleFill.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from "react"; + +export function MingcuteAddCircleFill(props: SVGProps) { + return ( + + + + + + + ); +} diff --git a/src/icons/MingcuteAwardLine.tsx b/src/icons/MingcuteAwardLine.tsx new file mode 100644 index 0000000..788e752 --- /dev/null +++ b/src/icons/MingcuteAwardLine.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteAwardLine(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteAwardLine; diff --git a/src/icons/MingcuteBookmarkLine.tsx b/src/icons/MingcuteBookmarkLine.tsx new file mode 100644 index 0000000..cbf58e4 --- /dev/null +++ b/src/icons/MingcuteBookmarkLine.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteBookmarkLine(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteBookmarkLine; diff --git a/src/icons/MingcuteCalendar2Line.tsx b/src/icons/MingcuteCalendar2Line.tsx new file mode 100644 index 0000000..cef29b4 --- /dev/null +++ b/src/icons/MingcuteCalendar2Line.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteCalendar2Line(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteCalendar2Line; diff --git a/src/icons/MingcuteCommentLine.tsx b/src/icons/MingcuteCommentLine.tsx new file mode 100644 index 0000000..1a460ab --- /dev/null +++ b/src/icons/MingcuteCommentLine.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteCommentLine(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteCommentLine; diff --git a/src/icons/MingcuteEye2Line.tsx b/src/icons/MingcuteEye2Line.tsx new file mode 100644 index 0000000..55c35d4 --- /dev/null +++ b/src/icons/MingcuteEye2Line.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteEye2Line(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteEye2Line; diff --git a/src/icons/MingcuteMore2Line.tsx b/src/icons/MingcuteMore2Line.tsx new file mode 100644 index 0000000..a6bbec8 --- /dev/null +++ b/src/icons/MingcuteMore2Line.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteMore2Line(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteMore2Line; diff --git a/src/icons/MingcutePencilLine.tsx b/src/icons/MingcutePencilLine.tsx new file mode 100644 index 0000000..87990d3 --- /dev/null +++ b/src/icons/MingcutePencilLine.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcutePencilLine(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcutePencilLine; diff --git a/src/icons/MingcutePhoneLine.tsx b/src/icons/MingcutePhoneLine.tsx new file mode 100644 index 0000000..40506ca --- /dev/null +++ b/src/icons/MingcutePhoneLine.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcutePhoneLine(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcutePhoneLine; diff --git a/src/icons/MingcuteShare2Line.tsx b/src/icons/MingcuteShare2Line.tsx new file mode 100644 index 0000000..3813ad0 --- /dev/null +++ b/src/icons/MingcuteShare2Line.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteShare2Line(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteShare2Line; diff --git a/src/icons/MingcuteThumbUp2Line.tsx b/src/icons/MingcuteThumbUp2Line.tsx new file mode 100644 index 0000000..03fa73f --- /dev/null +++ b/src/icons/MingcuteThumbUp2Line.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteThumbUp2Line(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteThumbUp2Line; diff --git a/src/icons/MingcuteWalkFill.tsx b/src/icons/MingcuteWalkFill.tsx new file mode 100644 index 0000000..7867d9c --- /dev/null +++ b/src/icons/MingcuteWalkFill.tsx @@ -0,0 +1,22 @@ +import React, { SVGProps } from "react"; + +export function MingcuteWalkFill(props: SVGProps) { + return ( + + + + + + + ); +} +export default MingcuteWalkFill; diff --git a/src/icons/OnlineTowCiecleFill.tsx b/src/icons/OnlineTowCiecleFill.tsx new file mode 100644 index 0000000..509d9c2 --- /dev/null +++ b/src/icons/OnlineTowCiecleFill.tsx @@ -0,0 +1,19 @@ +import React, { SVGProps } from "react"; + +export function OnlineTowCiecleFill(props: SVGProps) { + return ( + + + + + ); +} +export default OnlineTowCiecleFill; diff --git a/src/models/doctors.ts b/src/models/doctors.ts index 6745609..1f5fef9 100644 --- a/src/models/doctors.ts +++ b/src/models/doctors.ts @@ -2,12 +2,16 @@ import { DoctorModel } from "@/types/doctor.type"; export const doctorsData: DoctorModel[] = [ { - id: "97420f0d-b576-4f65-9ffc-a81b4b1b4e77", - name: "پوراندخت جعفري", - image: "", + slug: "9ffc", + name: "پوراندخت جعفری", + en_name: "dr-pourandokht-jafari", + price: 190000, + image: + "https://cdn.paziresh24.com/getImage/p24/search-women/8cd097208180a48ff5206f6122b9a5a5.jpg?size=150", isVerified: true, averageRating: 4.99, totalVotes: 294, + activeCounsulate: 325, address: "تهران، بین چهارراه بانک و میدان شهدا،ساختمان پزشکان مانا طبقه چهارم", nextAppointments: [ @@ -32,14 +36,59 @@ export const doctorsData: DoctorModel[] = [ en: "tehran", fa: "تهران", }, + about: ` +
      +

      دکتر پوراندخت جعفري یکی از متخصصان برجسته در زمینه پزشکی داخلی است. او دارای تجربه‌ای گسترده در درمان بیماری‌های مختلف می‌باشد.

      + +

      برای اطلاعات بیشتر می‌توانید با پزشک تماس بگیرید. همچنین، می‌توانید از تجربیات دیگر بیماران بهره‌مند شوید.

      + +

      توجه: همیشه قبل از شروع هر نوع درمانی با پزشک خود مشورت کنید.

      +
      + `, + ratingProgress: [ + { lable: "برخورد مناسب", rate: 5 }, + { lable: "ویزیت به موقع", rate: 5 }, + { lable: "هزینه ویزیت", rate: 4 }, + ], + doctorsReviews: [ + { + patientName: "محمدرضا", + isVisited: true, + date: "2025-01-25T12:30:00Z", + text: "دکتر جعفری بسیار خوش برخورد و با حوصله بودند، تشخیص عالی و درمان مؤثر داشتند.", + vote: 5, + }, + { + patientName: "الهام", + isVisited: false, + date: "2025-01-20T15:45:00Z", + text: "متأسفانه هنوز نوبت من نرسیده ولی از تعریف‌هایی که شنیدم مطمئنم پزشک خوبی هستند.", + vote: 4, + }, + ], + services: [ + "معاینه و تشخیص بیماری‌ها", + "مشاوره در زمینه پیشگیری از بیماری‌های قلبی", + "ارائه برنامه درمانی شخصی‌سازی‌شده", + "انجام تست‌های تشخیصی مانند نوار قلب و اکوکاردیوگرافی", + ], + characteristics: [ + "تجربه بالا در تشخیص و درمان بیماری‌ها", + "توانایی درمان بیماری‌های مزمن و پیشرفته", + "توانایی برقراری ارتباط مؤثر و انسانی با بیماران", + ], }, { - id: "e6719f23-e846-4a95-88f9-c013c5d9cb4f", + slug: "88f9", name: "مجتبی قدسی", - image: "", + en_name: "dr-mojtaba-ghodsi", + price: 100000, + image: + "https://cdn.paziresh24.com/getImage/p24/search-men/9b45c32fe70f1321b4b349bc1a5aff9c.jpeg?size=150", isVerified: true, averageRating: 4.92, totalVotes: 1487, + activeCounsulate: 35, address: "رشت، اول خیابان والی، خیابان شهیدان نوعی اقدم، نرسیده به بیمارستان امام خمینی، جنب داروخانه دکترنصیرپور", nextAppointments: [ @@ -64,14 +113,66 @@ export const doctorsData: DoctorModel[] = [ en: "rasht", fa: "رشت", }, + about: ` +
      +

      دکتر مجتبی قدسی یکی از متخصصان برجسته در زمینه بیماری‌های کودکان است. او دارای تجربه‌ای گسترده در درمان بیماری‌های مختلف می‌باشد.

      + +

      برای اطلاعات بیشتر می‌توانید با دکتر مجتبی قدسی تماس بگیرید. همچنین، می‌توانید از تجربیات دیگر بیماران بهره‌مند شوید.

      + +

      توجه: همیشه قبل از شروع هر نوع درمانی با پزشک خود مشورت کنید.

      +
      + `, + ratingProgress: [ + { lable: "برخورد مناسب", rate: 5 }, + { lable: "ویزیت به موقع", rate: 4 }, + { lable: "هزینه ویزیت", rate: 2 }, + ], + doctorsReviews: [ + { + patientName: "حسین", + isVisited: true, + date: "2025-01-28T09:15:00Z", + text: "پزشک بسیار متخصص و با دقت. رفتار عالی با کودکان و توصیه‌های مفید.", + vote: 5, + }, + { + patientName: "زهرا", + isVisited: true, + date: "2025-01-26T14:20:00Z", + text: "نسبت به هزینه‌ای که دریافت می‌کنند خدمات خیلی خوبی ارائه می‌دهند.", + vote: 4, + }, + { + patientName: "علی", + isVisited: false, + date: "2025-01-18T18:00:00Z", + text: "در انتظار نوبت هستم اما سیستم نوبت‌دهی بسیار سریع و کاربردی بود.", + vote: 4, + }, + ], + services: [ + "معاینه و تشخیص بیماری‌ها", + "مشاوره در زمینه پیشگیری از بیماری‌های قلبی", + "ارائه برنامه درمانی شخصی‌سازی‌شده", + "انجام تست‌های تشخیصی مانند نوار قلب و اکوکاردیوگرافی", + ], + characteristics: [ + "تجربه بالا در تشخیص و درمان بیماری‌ها", + "توانایی درمان بیماری‌های مزمن و پیشرفته", + "توانایی برقراری ارتباط مؤثر و انسانی با بیماران", + ], }, { - id: "4a7403d4-e0a2-406c-8dea-3e557bae54d2", + slug: "406c", name: "امیرحسین پورداود", - image: "", + en_name: "dr-amirhossein-pourdavood", + price: 150000, + image: + "https://cdn.paziresh24.com/getImage/p24/search-men/ac84246810671c2744de34cb4e938aa2.jpg?size=150", isVerified: true, averageRating: 5, totalVotes: 190, + activeCounsulate: 105, address: "بندرعباس، خیابان ۲۲ بهمن ،جنب بانک مسکن ،ساختمان حکیم ،طبقه سوم", nextAppointments: [ { date: "2025-01-08", time: "11:00" }, @@ -95,14 +196,59 @@ export const doctorsData: DoctorModel[] = [ en: "bandarabas", fa: "بندرعباس", }, + about: ` +
      +

      دکتر امیرحسین پورداود یکی از متخصصان برجسته در زمینه بیماری‌های پوست است. او دارای تجربه‌ای گسترده در درمان بیماری‌های مختلف می‌باشد.

      + +

      برای اطلاعات بیشتر می‌توانید با دکتر امیرحسین پورداود تماس بگیرید. همچنین، می‌توانید از تجربیات دیگر بیماران بهره‌مند شوید.

      + +

      توجه: همیشه قبل از شروع هر نوع درمانی با پزشک خود مشورت کنید.

      +
      + `, + ratingProgress: [ + { lable: "برخورد مناسب", rate: 4 }, + { lable: "ویزیت به موقع", rate: 3 }, + { lable: "هزینه ویزیت", rate: 1 }, + ], + doctorsReviews: [ + { + patientName: "فرزاد", + isVisited: true, + date: "2025-01-22T11:40:00Z", + text: "تجربه خوبی داشتم، مشاوره تخصصی و تجویز مناسب برای مشکلات پوستی من.", + vote: 5, + }, + { + patientName: "سمیرا", + isVisited: true, + date: "2025-01-21T10:30:00Z", + text: "نتایج درمانی بسیار خوب، اما هزینه کمی بالا بود.", + vote: 4, + }, + ], + services: [ + "معاینه و تشخیص بیماری‌ها", + "مشاوره در زمینه پیشگیری از بیماری‌های قلبی", + "ارائه برنامه درمانی شخصی‌سازی‌شده", + "انجام تست‌های تشخیصی مانند نوار قلب و اکوکاردیوگرافی", + ], + characteristics: [ + "تجربه بالا در تشخیص و درمان بیماری‌ها", + "توانایی درمان بیماری‌های مزمن و پیشرفته", + "توانایی برقراری ارتباط مؤثر و انسانی با بیماران", + ], }, { - id: "06d3a495-160d-4722-815e-286ff5d82ed2", - name: "اعظم قهساره اردستانی", - image: "", + slug: "160d", + name: "اعظم قهساره", + en_name: "dr-azam-ghahsareh-ardestani", + price: 350000, + image: + "https://cdn.paziresh24.com/getImage/p24/search-women/dc1c321cabe87e09772530d3480d2adc.jpg?size=150", isVerified: true, averageRating: 4.95, totalVotes: 759, + activeCounsulate: 5, address: "زاهدان, شهرک ولی عصر .بیمارستان فوق تخصصی میلاد کلینیک اطفال ونوزادان", nextAppointments: [ @@ -127,14 +273,52 @@ export const doctorsData: DoctorModel[] = [ en: "zahedan", fa: "زاهدان", }, + about: ` +
      +

      دکتر اعظم قهساره یکی از متخصصان برجسته در زمینه چشم است. او دارای تجربه‌ای گسترده در درمان بیماری‌های مختلف می‌باشد.

      + +

      برای اطلاعات بیشتر می‌توانید با دکتر اعظم قهساره تماس بگیرید. همچنین، می‌توانید از تجربیات دیگر بیماران بهره‌مند شوید.

      + +

      توجه: همیشه قبل از شروع هر نوع درمانی با پزشک خود مشورت کنید.

      +
      + `, + ratingProgress: [ + { lable: "برخورد مناسب", rate: 5 }, + { lable: "ویزیت به موقع", rate: 4 }, + { lable: "هزینه ویزیت", rate: 2 }, + ], + doctorsReviews: [ + { + patientName: "نیما", + isVisited: true, + date: "2025-01-29T13:50:00Z", + text: "دکتر اردستانی یکی از بهترین‌های تخصص چشم‌پزشکی است، توصیه می‌کنم.", + vote: 5, + }, + ], + services: [ + "معاینه و تشخیص بیماری‌ها", + "مشاوره در زمینه پیشگیری از بیماری‌های قلبی", + "ارائه برنامه درمانی شخصی‌سازی‌شده", + "انجام تست‌های تشخیصی مانند نوار قلب و اکوکاردیوگرافی", + ], + characteristics: [ + "تجربه بالا در تشخیص و درمان بیماری‌ها", + "توانایی درمان بیماری‌های مزمن و پیشرفته", + "توانایی برقراری ارتباط مؤثر و انسانی با بیماران", + ], }, { - id: "7f39ff5b-4c81-4c59-80fa-7872b675bb18", + slug: "4c81", name: "رضا پورعلی", - image: "", + en_name: "dr-reza-pourali", + price: 123000, + image: + "https://cdn.paziresh24.com/getImage/p24/search-men/c0a4da00da796354da26c3b1ed016ab3.png?size=150", isVerified: true, averageRating: 4.8259, totalVotes: 305, + activeCounsulate: 65, address: "کلینیک خیام بیمارستان تخصصی و فوق تخصصی حکیم|اردبیل, میدان بسیج، ابتدای جاده باغرود، مرکز اموزشی پژوهشی و درمانی حکیم (درمانگاه طب سنتی ; آدرس: کلینیک امام علی : بلوار جمهوری - بین جمهوری 6و 8)", nextAppointments: [ @@ -163,5 +347,46 @@ export const doctorsData: DoctorModel[] = [ en: "ardebil", fa: "اردبیل", }, + about: ` +
      +

      دکتر رضا پورعلی یکی از متخصصان برجسته در زمینه بیماری‌های قلب و عروق است. او دارای تجربه‌ای گسترده در درمان بیماری‌های مختلف می‌باشد.

      + +

      برای اطلاعات بیشتر می‌توانید با دکتر رضا پورعلی تماس بگیرید. همچنین، می‌توانید از تجربیات دیگر بیماران بهره‌مند شوید.

      + +

      توجه: همیشه قبل از شروع هر نوع درمانی با پزشک خود مشورت کنید.

      +
      + `, + ratingProgress: [ + { lable: "برخورد مناسب", rate: 5 }, + { lable: "ویزیت به موقع", rate: 4 }, + { lable: "هزینه ویزیت", rate: 2 }, + ], + doctorsReviews: [ + { + patientName: "سعید", + isVisited: true, + date: "2025-01-23T16:10:00Z", + text: "پزشک باتجربه و حرفه‌ای. درمان‌های مؤثری دارند و نتایج عالی بود.", + vote: 5, + }, + { + patientName: "مریم", + isVisited: false, + date: "2025-01-19T17:45:00Z", + text: "به‌زودی برای ویزیت خواهم رفت، اما شنیده‌ام که خیلی دقیق و کاربلد هستند.", + vote: 4, + }, + ], + services: [ + "معاینه و تشخیص بیماری‌ها", + "مشاوره در زمینه پیشگیری از بیماری‌های قلبی", + "ارائه برنامه درمانی شخصی‌سازی‌شده", + "انجام تست‌های تشخیصی مانند نوار قلب و اکوکاردیوگرافی", + ], + characteristics: [ + "تجربه بالا در تشخیص و درمان بیماری‌ها", + "توانایی درمان بیماری‌های مزمن و پیشرفته", + "توانایی برقراری ارتباط مؤثر و انسانی با بیماران", + ], }, ]; diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Black.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Black.woff2 new file mode 100644 index 0000000..251a923 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Black.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Bold.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Bold.woff2 new file mode 100644 index 0000000..f97f0db Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Bold.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraBold.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraBold.woff2 new file mode 100644 index 0000000..e2b2d31 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraBold.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraLight.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraLight.woff2 new file mode 100644 index 0000000..d019c92 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-ExtraLight.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Light.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Light.woff2 new file mode 100644 index 0000000..41d75fb Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Light.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Medium.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Medium.woff2 new file mode 100644 index 0000000..2721237 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Medium.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Regular.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Regular.woff2 new file mode 100644 index 0000000..ac7cff8 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Regular.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-SemiBold.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-SemiBold.woff2 new file mode 100644 index 0000000..359e57e Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-SemiBold.woff2 differ diff --git a/src/public/fonts/vazirmatn/Vazirmatn-FD-Thin.woff2 b/src/public/fonts/vazirmatn/Vazirmatn-FD-Thin.woff2 new file mode 100644 index 0000000..d9cfb37 Binary files /dev/null and b/src/public/fonts/vazirmatn/Vazirmatn-FD-Thin.woff2 differ diff --git a/src/types/doctor.type.ts b/src/types/doctor.type.ts index 2deb582..05da686 100644 --- a/src/types/doctor.type.ts +++ b/src/types/doctor.type.ts @@ -14,9 +14,20 @@ export interface Appointment { time: string; } +export type DoctorsReviews = { + patientName: string; + isVisited: boolean; + date: string; + text: string; + vote: number; +}; + +export type RatingProgress = { lable: string; rate: number }; export interface DoctorModel { - id: string; + slug: string; name: string; + en_name: string; + price: number; image: string; isVerified: boolean; averageRating: number; @@ -29,4 +40,10 @@ export interface DoctorModel { specialty: Specialty; gender: Gender; city: City; + about: string; + activeCounsulate: number; + ratingProgress: RatingProgress[]; + doctorsReviews: DoctorsReviews[]; + services: string[]; + characteristics: string[]; } diff --git a/src/types/select-option.type.ts b/src/types/select-option.type.ts new file mode 100644 index 0000000..273dbdf --- /dev/null +++ b/src/types/select-option.type.ts @@ -0,0 +1,5 @@ +export type SelectOptionType = { + id: number; + value: string; + label: string; +}; diff --git a/src/utils/timeAgo.ts b/src/utils/timeAgo.ts new file mode 100644 index 0000000..403625b --- /dev/null +++ b/src/utils/timeAgo.ts @@ -0,0 +1,33 @@ +export function timeAgo(dateString: string): string { + const createdAt = new Date(dateString); + + const iranTimeOffset = 3.5 * 60 * 60 * 1000; + const now = new Date(Date.now() + iranTimeOffset); + + const diffInSeconds = Math.floor( + (now.getTime() - createdAt.getTime()) / 1000, + ); + + if (diffInSeconds < 300) return "چند لحظه قبل"; + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) return `${diffInMinutes} دقیقه قبل`; + + const diffInHours = Math.floor(diffInMinutes / 60); + const remainingMinutes = diffInMinutes % 60; + + if (diffInHours < 24) { + return remainingMinutes === 0 + ? `${diffInHours} ساعت قبل` + : `${diffInHours} ساعت و ${remainingMinutes} دقیقه قبل`; + } + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 30) return `${diffInDays} روز پیش`; + + const diffInMonths = Math.floor(diffInDays / 30); + if (diffInMonths < 12) return `${diffInMonths} ماه پیش`; + + const diffInYears = Math.floor(diffInDays / 365); + return `${diffInYears} سال پیش`; +}