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