ProvidersStep: list-map split layout with provider card list (left) and map slot (right). SearchBar + filter chips + radiogroup card selection pattern. Back link, results count with aria-live, grief-sensitive copy with pre-planning variant. Pure presentation. Audit fixes (18/20): - P1: Move role="radio" + aria-checked onto ProviderCard (focusable) - P3: Add aria-live="polite" on results count - ProviderCard: extend props to accept HTML/ARIA passthrough, add rest spread to Card for role/aria-checked/aria-label support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
274 lines
9.8 KiB
TypeScript
274 lines
9.8 KiB
TypeScript
import React from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
import { Card } from '../../atoms/Card';
|
|
import { Badge } from '../../atoms/Badge';
|
|
import type { BadgeColor } from '../../atoms/Badge/Badge';
|
|
import { Typography } from '../../atoms/Typography';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Own props for the FA ProviderCard molecule (excludes HTML/Card passthrough) */
|
|
export interface ProviderCardOwnProps {
|
|
/** Provider display name */
|
|
name: string;
|
|
/** Location text (suburb, city) */
|
|
location: string;
|
|
/** Whether this provider is a verified/trusted partner */
|
|
verified?: boolean;
|
|
/** Hero image URL — only rendered when verified */
|
|
imageUrl?: string;
|
|
/** Provider logo URL — rounded rectangle overlay on image, only rendered when verified */
|
|
logoUrl?: string;
|
|
/** Average rating (e.g. 4.8). Omit to hide reviews. */
|
|
rating?: number;
|
|
/** Number of reviews (e.g. 127). Omit to hide review count. */
|
|
reviewCount?: number;
|
|
/** Capability badge label (e.g. "Online Arrangement") */
|
|
capabilityLabel?: string;
|
|
/** Capability badge colour intent — maps to Badge colour */
|
|
capabilityColor?: BadgeColor;
|
|
/** Tooltip description for the capability badge (shown on hover/focus) */
|
|
capabilityDescription?: string;
|
|
/** Starting price in dollars (shown as "From $X") */
|
|
startingPrice?: number;
|
|
/** Whether this card is the currently selected provider */
|
|
selected?: boolean;
|
|
/** Click handler — entire card is clickable */
|
|
onClick?: () => void;
|
|
/** MUI sx prop for style overrides */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
/** Props for the FA ProviderCard molecule — includes HTML/ARIA passthrough to Card */
|
|
export type ProviderCardProps = ProviderCardOwnProps &
|
|
Omit<React.HTMLAttributes<HTMLDivElement>, keyof ProviderCardOwnProps>;
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
|
|
const LOGO_BORDER_RADIUS = 'var(--fa-provider-card-logo-border-radius)';
|
|
const IMAGE_HEIGHT = 'var(--fa-provider-card-image-height)';
|
|
const CONTENT_PADDING = 'var(--fa-provider-card-content-padding)';
|
|
const CONTENT_GAP = 'var(--fa-provider-card-content-gap)';
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Provider listing card for the FA design system.
|
|
*
|
|
* Displays a funeral provider in the provider select screen's scrollable
|
|
* list. Supports verified (paid partner) and unverified (scraped listing)
|
|
* providers with consistent text alignment for scan readability.
|
|
*
|
|
* **Verified providers** get a hero image, logo (rounded rectangle inside
|
|
* image area), and "Verified" badge. **Unverified providers** show text
|
|
* content only with a subtle top accent bar for visibility in mixed lists.
|
|
*
|
|
* Composes: Card (interactive, padding="none"), Badge, Typography.
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* <ProviderCard
|
|
* name="H.Parsons Funeral Directors"
|
|
* location="Wollongong"
|
|
* verified
|
|
* imageUrl="/images/parsons-hero.jpg"
|
|
* logoUrl="/images/parsons-logo.png"
|
|
* rating={4.8}
|
|
* reviewCount={127}
|
|
* capabilityLabel="Online Arrangement"
|
|
* capabilityColor="success"
|
|
* startingPrice={900}
|
|
* onClick={() => navigate(`/providers/parsons`)}
|
|
* />
|
|
* ```
|
|
*/
|
|
export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
|
(
|
|
{
|
|
name,
|
|
location,
|
|
verified = false,
|
|
imageUrl,
|
|
logoUrl,
|
|
rating,
|
|
reviewCount,
|
|
capabilityLabel,
|
|
capabilityColor = 'default',
|
|
capabilityDescription,
|
|
selected = false,
|
|
startingPrice,
|
|
onClick,
|
|
sx,
|
|
...rest
|
|
},
|
|
ref,
|
|
) => {
|
|
const showImage = verified && imageUrl;
|
|
const showLogo = verified && logoUrl;
|
|
|
|
return (
|
|
<Card
|
|
ref={ref}
|
|
interactive
|
|
selected={selected}
|
|
padding="none"
|
|
onClick={onClick}
|
|
{...rest}
|
|
sx={[
|
|
{
|
|
overflow: 'hidden',
|
|
// Override Card's default hover bg fill — shadow lift is enough
|
|
// for listing cards. The grey bg fill blends into the shadow.
|
|
'&:hover': {
|
|
backgroundColor: 'background.paper',
|
|
},
|
|
// Unverified cards: subtle top accent so they don't get lost
|
|
// in a mixed list. Verified cards have the hero image as anchor.
|
|
...(!showImage && {
|
|
borderTop: '3px solid var(--fa-color-border-default)',
|
|
}),
|
|
},
|
|
...(Array.isArray(sx) ? sx : [sx]),
|
|
]}
|
|
>
|
|
{/* ── Image area (verified only) ── */}
|
|
{showImage && (
|
|
<Box
|
|
role="img"
|
|
aria-label={`Photo of ${name}`}
|
|
sx={{
|
|
position: 'relative',
|
|
height: IMAGE_HEIGHT,
|
|
backgroundImage: `url(${imageUrl})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
backgroundColor: 'var(--fa-color-brand-50)', // warm fallback if image fails
|
|
}}
|
|
>
|
|
{/* Verified badge */}
|
|
<Box sx={{ position: 'absolute', top: 12, right: 12 }}>
|
|
<Badge variant="filled" color="brand" size="medium" icon={<VerifiedOutlinedIcon />}>
|
|
Verified
|
|
</Badge>
|
|
</Box>
|
|
|
|
{/* Logo — fully inside image area, bottom-left */}
|
|
{showLogo && (
|
|
<Box
|
|
component="img"
|
|
src={logoUrl}
|
|
alt={`${name} logo`}
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: 12,
|
|
left: CONTENT_PADDING,
|
|
width: LOGO_SIZE,
|
|
height: LOGO_SIZE,
|
|
borderRadius: LOGO_BORDER_RADIUS,
|
|
objectFit: 'cover',
|
|
backgroundColor: 'background.paper',
|
|
boxShadow: 'var(--fa-shadow-sm)',
|
|
border: '2px solid var(--fa-color-white)',
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── Content area ── */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: CONTENT_GAP,
|
|
p: CONTENT_PADDING,
|
|
}}
|
|
>
|
|
{/* Provider name — full width, no logo competition */}
|
|
<Typography variant="h5" maxLines={2}>
|
|
{name}
|
|
</Typography>
|
|
|
|
{/* Price — "Packages from $X" with subtle size differentiation */}
|
|
{startingPrice != null && (
|
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Packages from
|
|
</Typography>
|
|
<Typography
|
|
variant="h6"
|
|
component="span"
|
|
color="primary"
|
|
sx={{ fontWeight: 500, letterSpacing: '-0.01em' }}
|
|
>
|
|
${startingPrice.toLocaleString('en-AU')}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Meta row: location + reviews */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
{/* Location */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
|
|
<Typography variant="caption" color="text.secondary">
|
|
{location}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Reviews */}
|
|
{rating != null && (
|
|
<Box
|
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
|
aria-label={`Rated ${rating} out of 5${reviewCount != null ? `, ${reviewCount} reviews` : ''}`}
|
|
>
|
|
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
|
<Typography variant="caption" color="text.secondary">
|
|
{rating}
|
|
{reviewCount != null && ` (${reviewCount.toLocaleString('en-AU')})`}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Capability badge — trailing info icon signals hover-for-definition */}
|
|
{capabilityLabel && (
|
|
<Box>
|
|
{capabilityDescription ? (
|
|
<Tooltip title={capabilityDescription} arrow placement="top" enterTouchDelay={0}>
|
|
<Badge color={capabilityColor} size="medium" sx={{ cursor: 'help' }}>
|
|
{capabilityLabel}
|
|
<InfoOutlinedIcon />
|
|
</Badge>
|
|
</Tooltip>
|
|
) : (
|
|
<Badge color={capabilityColor} size="medium">
|
|
{capabilityLabel}
|
|
<InfoOutlinedIcon />
|
|
</Badge>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Card>
|
|
);
|
|
},
|
|
);
|
|
|
|
ProviderCard.displayName = 'ProviderCard';
|
|
export default ProviderCard;
|