import { type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { type VariantProps } from 'class-variance-authority'; export type PracticeResultsVariant = 'split-list-map' | 'map-first' | 'rich-cards'; /** Heading levels the results title accepts. */ export type PracticeResultsHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; export type PracticeResultsSort = 'distance' | 'rating' | 'next-available' | 'price' | 'relevance'; /** * Where the result-selection event originated. * * - `card` — patient clicked the card body. The kit's behaviour is * **sync only** (centre the map on the matching pin) — it does NOT * navigate. The consumer's `onResultSelect` handler should typically * no-op or augment with hover-like analytics for this source. * - `pin` — patient clicked a map pin. Sync-only (scroll list to the * matching card); no navigation. * - `open-button` — patient clicked the explicit "Open" CTA on a card. * Navigates to the practice page. Booking-website's consumer routes * to `result.href` on this source value. * - `book-button` — patient clicked the always-visible "Book" CTA on a * card (1.7.0). Navigates to the booking flow: the concrete * `nextAvailableSlot.bookingHref` when known, else `bookingHref`, * else `href`. Both CTAs are real anchors, so native navigation * happens whether or not the consumer handles the source — the value * exists so booking intent from the SERP can be attributed. * * `open-button` and `book-button` are the only sources that should * drive navigation. */ export type PracticeResultsSelectSource = 'card' | 'pin' | 'open-button' | 'book-button'; export interface PracticeResultsBounds { north: number; south: number; east: number; west: number; } export interface PracticeResult { id: string; /** Stable URL for the practice or operator detail page. */ href: string; name: string; /** Profession or specialty caption. */ subtitle?: string; /** * One-line slogan shown under the caption on every card * (booking-website maps `bookingSlogan` here). Added in 1.7.0. */ tagline?: string; /** * Short free-text blurb about the practice. Rendered on every card, * clamped to two lines on compact cards and three on rich cards * (booking-website maps `bookingDescription` here). Added in 1.7.0. */ description?: string; /** * Destination of the always-visible "Book" CTA. Defaults to `href` * (the practice page hosts the booking flow). A * `nextAvailableSlot.bookingHref` takes precedence when present so * the CTA deep-links to the concrete slot. Added in 1.7.0. */ bookingHref?: string; /** Avatar / logo URL. */ imageUrl?: string; /** * Text alternative for the photo's ``, for image search and crawlers. * Pass the practice name. Leave it out and the photo stays decorative * (`alt=""`), as before. Added in 1.91.0. */ imageAlt?: string; /** Practice location. Required by the two map variants. */ location?: { lat: number; lng: number; /** Display string e.g. "Via Brera 14, 20121 Milano". */ address?: string; /** Distance from search centre, in km. Rendered by rich-cards. */ distanceKm?: number; }; rating?: { value: number; count: number; }; /** ISO 639-1 codes. Rendered as chips by rich-cards. */ languages?: string[]; /** Short brand names. Rendered as chips by rich-cards. */ insurances?: string[]; /** Anxious-patient friendly — renders an icon chip on rich-cards. */ specializedInFearPatients?: boolean; /** * First-visit price in minor currency units, formatted by the kit. * Takes precedence over the older `firstVisitPriceCents` field when * `minCents` is finite; otherwise the older field is used. * Fixed zero means "Free"; from/range zero stays numeric. A range * without a finite maximum above its minimum renders as a starting price. */ firstVisitPrice?: { kind: 'fixed' | 'from' | 'range'; minCents: number; maxCents?: number; /** Short practice-written plain text, without trailing punctuation. */ label?: string; }; /** * Older fixed-price form, kept for booking-website compatibility. * Use `firstVisitPrice` for new integrations; a finite minimum wins when * both are set. * Price in the smallest currency unit (cents / centesimi). * Rendered as a labelled "First visit cost" chip on every card in both * densities (booking-website maps the practice's long-standing * first-visit price setting here). Same convention as * `ContactProfileCard.firstVisitPriceCents` — the KIT formats, the * consumer never hands over a pre-rendered string. * * `0` is a real value meaning "free" and renders the translated word * "Free" (`common.freeOfCharge`), never a zero amount. Omit the * field (or pass `undefined`) when the practice has not set a price — * the label and chip disappear completely. Added for HTP-6136. */ firstVisitPriceCents?: number; /** * ISO-4217 currency code for either first-visit price form. Defaults to * `EUR`. Added for HTP-6136. */ currencyCode?: string; /** * Next available slot — rendered as a "Next available …" caption on * every card. When present, the card's Book CTA deep-links to * `bookingHref` and reads the slot-aware "Book at {{time}}" label. */ nextAvailableSlot?: { /** ISO datetime, e.g. "2026-05-23T10:30:00+02:00". */ dateTime: string; /** Direct deep-link to the kit's Booking with this slot pre-selected. */ bookingHref: string; }; /** Opaque payload echoed back in onResultSelect. */ __payload?: unknown; } export interface PracticeResultsValue { /** Selected facets — keys match consumer's `facets[].key`. */ facets?: Record; /** Sort key. Default `'distance'` when searchCentre is set, otherwise `'relevance'`. */ sort?: PracticeResultsSort; } /** Shared facet shape — same as PatientSearch. */ export interface FacetOption { value: string; label: string; } export interface FacetGroup { key: string; label: string; /** Non-empty = dropdown of selectable options. */ options: FacetOption[]; multi?: boolean; /** * Facet behaviour. `'boolean'` renders a single on/off toggle chip that * keeps results whose `field` (default: `key`) is `true`. `'options'` * forces dropdown semantics. When omitted, non-empty `options` means * dropdown; an empty `options` array falls back to the legacy boolean * degrade ONLY when at least one result actually carries a boolean at * `field` — otherwise the group is not rendered and not filtered on. * (Before 1.6.0 an empty-options group always became a boolean toggle * keyed by `key`; with missing data or a mismatched field name a single * click filtered out every result.) Added in 1.6.0. */ type?: 'options' | 'boolean'; /** Result field a boolean facet reads — defaults to `key`. Added in 1.6.0. */ field?: string; } export interface PracticeResultsHandle { getVariant: () => PracticeResultsVariant; /** Scroll a specific result into view (useful for deep-link → highlight). */ scrollToResult: (id: string) => void; /** Imperatively focus a pin on the map (no-op on rich-cards). */ focusPin: (id: string) => void; } export interface PracticeResultsProps extends Omit, 'aria-label' | 'onChange' | 'onSelect' | 'results' | 'defaultValue'>, VariantProps { /** Default: 'split-list-map'. Pass to override (PostHog / dev-menu). */ variant?: PracticeResultsVariant; results: PracticeResult[]; /** Total count for the "{{count}} risultati" header. May differ from results.length. */ totalCount?: number; /** * Visible search location. Drives the distance figures and the header * label. On the map it is the starting view, and the view when no * result has a location: as soon as results with a location are listed, * the map fits them all into view instead (capped at zoom 15). */ searchCentre?: { lat: number; lng: number; label?: string; }; facets?: FacetGroup[]; googleMapsApiKey?: string; /** * Google Maps Map ID — required when consumers use Google's * `AdvancedMarkerElement`. Forwarded to every internal `` * instance (split-list-map's desktop + mobile-sheet column, * map-first's main canvas). `rich-cards` thumbnails are unaffected * — they're static `` fetches, not the JS SDK. * * When omitted, MapView falls back to the kit's inline-style * theming (`getGoogleMapsTheme()`), same as before. */ mapId?: string; /** * Map zoom before the results are fitted, and with no results to fit. * Default 13. */ defaultZoom?: number; /** Actual settled viewport, including initial load and refits (split + map-first only). */ onBoundsChange?: (bounds: PracticeResultsBounds) => void; /** * GDPR consent gate, forwarded to the embedded MapView. When `false` * (default) the kit renders the same consent placeholder MapView ships * by default — third-party scripts never load until the consumer * captures opt-in. Also gates the rich-cards static thumbnails (which * are still IP-disclosing third-party loads). */ consentGranted?: boolean; /** Fires when the patient clicks the "Load map" CTA in the consent placeholder. */ onConsentRequest?: () => void; /** * @default false * 0.31.4: bypass the embedded ``'s GDPR consent placeholder * entirely. Use when the consumer's page already gates the Google * Maps loader script at a higher level (e.g. Usercentrics / OneTrust * / any CMP that decides per-cookie-category whether to load Google * Maps). Forwarded to every internal `` instance — * `split-list-map`'s right column and `map-first`'s main canvas. * `rich-cards` static thumbnails are unaffected (they dropped the * consent gate in 0.31.3 — see CHANGELOG). * * When `true`, `consentGranted` and `onConsentRequest` are ignored: * the kit assumes the CMP at the page level has already made the * call and the map loader script is either present or properly * blocked. Setting both `consentGranted={false}` and * `skipConsentGate={true}` is well-defined — gate skipped, no * placeholder rendered. */ skipConsentGate?: boolean; /** Fires when the patient scrolls to the bottom of the list. */ onLoadMore?: () => void; /** True while the next page is loading — kit shows a Spinner at the list tail. */ loadingMore?: boolean; value: PracticeResultsValue; onChange: (next: PracticeResultsValue) => void; onResultSelect: (result: PracticeResult, source: PracticeResultsSelectSource) => void; /** * Initial loading skeletons. Per SSR-first design, skeletons render * only when `results.length === 0 && loading === true` so a server- * rendered list isn't flashed away on hydration. */ loading?: boolean; /** Custom empty-state slot. Defaults to the localised EmptyState. */ emptyResultsSlot?: ReactNode; /** Custom error slot. Renders when `error` is truthy. */ errorSlot?: ReactNode; /** Truthy = render the error slot. Pair with `onRetry` for the default slot. */ error?: boolean; /** Retry handler for the default error slot's CTA. */ onRetry?: () => void; /** Heading rendered above the results (e.g. "I migliori Dentisti a Milano"). */ heading?: string; /** Results heading level (default 2); card names use the next level, capped at 6. */ headingLevel?: PracticeResultsHeadingLevel; /** Intro text below the heading. */ intro?: string; /** * Label for the per-card "Open" button. Defaults to * `t('practiceResults.card.open')` ("Apri" / "Open"). The Open button * is the **only** card affordance that fires `onResultSelect` with * `source: 'open-button'`. Card-body clicks and pin clicks fire with * `'card'` / `'pin'` and are sync-only. */ cardOpenButtonLabel?: string; /** * Label for the per-card "Book" button (1.7.0). Defaults to * `t('practiceResults.card.book')` ("Prenota" / "Book"). The button * renders on every card in every variant and fires `onResultSelect` * with `source: 'book-button'`. When the result carries a * `nextAvailableSlot` the label switches to the slot-aware * `t('practiceResults.nextSlot.cta')` ("Book at {{time}}") regardless * of this override — the time is the message. */ cardBookButtonLabel?: string; /** * Whether each card renders the secondary "Open" button next to * "Book". Defaults to `true`. Set `false` on a surface where both * buttons would lead to the same page, so the card carries one * clear call to action: the booking site's practice page hosts the * booking flow, so its cards keep only "Book". `onResultSelect` * never fires with `source: 'open-button'` while hidden. */ showCardOpenButton?: boolean; 'aria-label'?: string; /** Agent-readiness instance id. */ id?: string; } declare const rootVariants: (props?: ({ variant?: "split-list-map" | "map-first" | "rich-cards" | null | undefined; } & import("class-variance-authority/types").ClassProp) | undefined) => string; export declare const PracticeResults: import("react").ForwardRefExoticComponent>; export {}; //# sourceMappingURL=practice-results.d.ts.map