Files
Parsons/src/components/molecules/ProviderCard/ProviderCard.tsx
Richie fa20599b67 Add ProvidersStep page (wizard step 2) + audit fixes
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>
2026-03-29 14:36:27 +11:00

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;