import { FunctionComponent, memo } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { Avatar, Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material'; import AppIcon from '../common/AppIcon'; import TrustBadge from '../TrustBadge'; import PriceDisplay from '../PriceDisplay'; import { formatNumber } from '@/utils'; import type { NurseSearchResult } from '@/services/search/types'; export interface NurseResultCardProps { /** One search-result row (a bookable variant in a covered area). */ nurse: NurseSearchResult; /** * The service/variant label for this row — the row **is** a variant, so the card must name what is * being bought. `NurseSearchResultDto` has no display name yet (REQ-040); until it lands, the page * passes the row's **category** name (from the cached catalog reference data) so multi-variant nurses * are at least distinguishable by price row. The card stays data-agnostic — swap the caller's label * source to the served `variantDisplayName` once the REQ lands, no card change needed. */ serviceLabel: string; /** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */ onSelect: (nurse: NurseSearchResult) => void; } function ratingText(rating: number, locale: string): string { return formatNumber(rating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } /** * The C2 result card — the four-second decision unit. Avatar, name, the reused tappable ✓ تاییدشده * verified badge (opens the verification explainer), the service/variant label, a quiet gender chip * (same-gender matching is load-bearing), completed-visits count, rating + review count, an optional * distance chip, an optional one-line top-review tag (only when served), and the "from X تومان/ساعت" * rate. Presentational + memoized so a list of N cards doesn't re-render on unrelated state — pass a * stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the search-index invariant, * so the badge is always shown. * @component NurseResultCard */ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps) => { const t = useTranslations('search'); const locale = useLocale(); const name = nurse.nurseName.trim() || t('unnamed_nurse'); const initial = name.charAt(0); const distance = nurse.distanceKm != null ? formatNumber(nurse.distanceKm, locale, { maximumFractionDigits: 1 }) : null; return ( onSelect(nurse)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(nurse); } }} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', cursor: 'pointer', transition: 'border-color 120ms ease', '&:hover': { borderColor: 'var(--bal-primary)' }, '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 }, }} > {initial} {name} {serviceLabel} {nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''} {t('completed_visits', { count: formatNumber(nurse.totalCompletedBookings, locale) })} {ratingText(nurse.averageRating, locale)} {t('reviews_count', { count: nurse.totalReviews })} {distance != null ? ( {t('distance_km', { km: distance })} ) : null} {nurse.topReviewTag ? ( «{nurse.topReviewTag}» ) : null} {t('price_from')} ); }; /** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta * row, rating row, price row) so a loading list doesn't jump when data lands. */ const NurseResultCardSkeleton: FunctionComponent = () => ( ); export default Object.assign(memo(NurseResultCard), { Skeleton: NurseResultCardSkeleton });