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>
This commit is contained in:
@@ -13,8 +13,8 @@ import { Typography } from '../../atoms/Typography';
|
|||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Props for the FA ProviderCard molecule */
|
/** Own props for the FA ProviderCard molecule (excludes HTML/Card passthrough) */
|
||||||
export interface ProviderCardProps {
|
export interface ProviderCardOwnProps {
|
||||||
/** Provider display name */
|
/** Provider display name */
|
||||||
name: string;
|
name: string;
|
||||||
/** Location text (suburb, city) */
|
/** Location text (suburb, city) */
|
||||||
@@ -45,6 +45,10 @@ export interface ProviderCardProps {
|
|||||||
sx?: SxProps<Theme>;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
|
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
|
||||||
@@ -102,6 +106,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
|||||||
startingPrice,
|
startingPrice,
|
||||||
onClick,
|
onClick,
|
||||||
sx,
|
sx,
|
||||||
|
...rest
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -115,6 +120,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
padding="none"
|
padding="none"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
{...rest}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|||||||
302
src/components/pages/ProvidersStep/ProvidersStep.stories.tsx
Normal file
302
src/components/pages/ProvidersStep/ProvidersStep.stories.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ProvidersStep } from './ProvidersStep';
|
||||||
|
import type { ProviderData, ProviderFilter } from './ProvidersStep';
|
||||||
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FALogo = () => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/brandlogo/logo-full.svg"
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/brandlogo/logo-short.svg"
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nav = (
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogo />}
|
||||||
|
items={[
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
|
{ label: 'Log in', href: '/login' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockProviders: ProviderData[] = [
|
||||||
|
{
|
||||||
|
id: 'parsons',
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
verified: true,
|
||||||
|
imageUrl: 'https://placehold.co/600x200/E8E0D6/8B6F47?text=H.Parsons',
|
||||||
|
logoUrl: 'https://placehold.co/64x64/FEF9F5/BA834E?text=HP',
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 7,
|
||||||
|
startingPrice: 900,
|
||||||
|
distanceKm: 2.3,
|
||||||
|
description:
|
||||||
|
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funeral Services',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: true,
|
||||||
|
imageUrl: 'https://placehold.co/600x200/D7E1E2/4C5B6B?text=Rankins',
|
||||||
|
logoUrl: 'https://placehold.co/64x64/F2F5F6/4C5B6B?text=R',
|
||||||
|
rating: 4.8,
|
||||||
|
reviewCount: 23,
|
||||||
|
startingPrice: 1200,
|
||||||
|
distanceKm: 5.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wollongong-city',
|
||||||
|
name: 'Wollongong City Funerals',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.2,
|
||||||
|
reviewCount: 15,
|
||||||
|
startingPrice: 750,
|
||||||
|
distanceKm: 6.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'easy-funerals',
|
||||||
|
name: 'Easy Funerals',
|
||||||
|
location: 'Sydney, NSW',
|
||||||
|
verified: true,
|
||||||
|
imageUrl: 'https://placehold.co/600x200/F0F7F0/3B7A3B?text=Easy+Funerals',
|
||||||
|
logoUrl: 'https://placehold.co/64x64/F0F7F0/3B7A3B?text=EF',
|
||||||
|
rating: 4.5,
|
||||||
|
reviewCount: 42,
|
||||||
|
startingPrice: 850,
|
||||||
|
distanceKm: 12.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'botanical',
|
||||||
|
name: 'Botanical Funerals',
|
||||||
|
location: 'Newtown, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.9,
|
||||||
|
reviewCount: 8,
|
||||||
|
startingPrice: 2100,
|
||||||
|
distanceKm: 15.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultFilters: ProviderFilter[] = [
|
||||||
|
{ label: 'Funeral Type', active: false },
|
||||||
|
{ label: 'Verified Only', active: false },
|
||||||
|
{ label: 'Under $1,500', active: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const meta: Meta<typeof ProvidersStep> = {
|
||||||
|
title: 'Pages/ProvidersStep',
|
||||||
|
component: ProvidersStep,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ProvidersStep>;
|
||||||
|
|
||||||
|
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fully interactive — search, filter, select a provider, continue */
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [filters, setFilters] = useState(defaultFilters);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const filtered = mockProviders.filter((p) =>
|
||||||
|
p.name.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
setError('Please choose a funeral provider to continue.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
alert(`Continue with provider: ${selectedId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={filtered}
|
||||||
|
selectedProviderId={selectedId}
|
||||||
|
onSelectProvider={(id) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
filters={filters}
|
||||||
|
onFilterToggle={(i) =>
|
||||||
|
setFilters((prev) => prev.map((f, idx) => (idx === i ? { ...f, active: !f.active } : f)))
|
||||||
|
}
|
||||||
|
onContinue={handleContinue}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
error={error}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── With selected provider ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Provider already selected — ready to continue */
|
||||||
|
export const WithSelection: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('parsons');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={mockProviders}
|
||||||
|
selectedProviderId={selectedId}
|
||||||
|
onSelectProvider={setSelectedId}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-planning variant ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Pre-planning flow — softer copy */
|
||||||
|
export const PrePlanning: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={mockProviders}
|
||||||
|
selectedProviderId={selectedId}
|
||||||
|
onSelectProvider={setSelectedId}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
isPrePlanning
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Validation error ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** No provider selected, error shown */
|
||||||
|
export const WithError: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={mockProviders}
|
||||||
|
selectedProviderId={selectedId}
|
||||||
|
onSelectProvider={setSelectedId}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => {}}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
error="Please choose a funeral provider to continue."
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Empty results ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Search yielded no results */
|
||||||
|
export const EmptyResults: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [query, setQuery] = useState('xyz');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={[]}
|
||||||
|
selectedProviderId={null}
|
||||||
|
onSelectProvider={() => {}}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => {}}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Loading state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Continue button loading */
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={mockProviders}
|
||||||
|
selectedProviderId="parsons"
|
||||||
|
onSelectProvider={() => {}}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => {}}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
loading
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Single provider ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Only one provider available */
|
||||||
|
export const SingleProvider: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProvidersStep
|
||||||
|
providers={[mockProviders[0]]}
|
||||||
|
selectedProviderId={selectedId}
|
||||||
|
onSelectProvider={setSelectedId}
|
||||||
|
searchQuery={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
257
src/components/pages/ProvidersStep/ProvidersStep.tsx
Normal file
257
src/components/pages/ProvidersStep/ProvidersStep.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
|
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||||
|
import { SearchBar } from '../../molecules/SearchBar';
|
||||||
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Provider data for display in the list */
|
||||||
|
export interface ProviderData {
|
||||||
|
/** Unique provider ID (funeralBrandID) */
|
||||||
|
id: string;
|
||||||
|
/** Provider display name */
|
||||||
|
name: string;
|
||||||
|
/** Location text (suburb, city) */
|
||||||
|
location: string;
|
||||||
|
/** Whether this is a verified/trusted partner */
|
||||||
|
verified?: boolean;
|
||||||
|
/** Hero image URL */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Provider logo URL */
|
||||||
|
logoUrl?: string;
|
||||||
|
/** Average rating (e.g. 4.8) */
|
||||||
|
rating?: number;
|
||||||
|
/** Number of reviews */
|
||||||
|
reviewCount?: number;
|
||||||
|
/** Starting price in dollars */
|
||||||
|
startingPrice?: number;
|
||||||
|
/** Distance from user in km */
|
||||||
|
distanceKm?: number;
|
||||||
|
/** Brief description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter chip state */
|
||||||
|
export interface ProviderFilter {
|
||||||
|
/** Filter label */
|
||||||
|
label: string;
|
||||||
|
/** Whether this filter is active */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the ProvidersStep page component */
|
||||||
|
export interface ProvidersStepProps {
|
||||||
|
/** List of providers to display */
|
||||||
|
providers: ProviderData[];
|
||||||
|
/** Currently selected provider ID */
|
||||||
|
selectedProviderId: string | null;
|
||||||
|
/** Callback when a provider is selected */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Search query value */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Callback when search query changes */
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
/** Callback when search is submitted */
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
/** Filter chips */
|
||||||
|
filters?: ProviderFilter[];
|
||||||
|
/** Callback when a filter chip is toggled */
|
||||||
|
onFilterToggle?: (index: number) => void;
|
||||||
|
/** Callback for the Continue button */
|
||||||
|
onContinue: () => void;
|
||||||
|
/** Callback for the Back button */
|
||||||
|
onBack: () => void;
|
||||||
|
/** Validation error message */
|
||||||
|
error?: string;
|
||||||
|
/** Whether the Continue action is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Map panel content — slot for future map integration */
|
||||||
|
mapPanel?: React.ReactNode;
|
||||||
|
/** Navigation bar — passed through to WizardLayout */
|
||||||
|
navigation?: React.ReactNode;
|
||||||
|
/** Whether this is a pre-planning flow (shows softer copy) */
|
||||||
|
isPrePlanning?: boolean;
|
||||||
|
/** MUI sx prop for the root */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 — Provider selection page for the FA arrangement wizard.
|
||||||
|
*
|
||||||
|
* List + Map split layout. Left panel shows a scrollable list of
|
||||||
|
* provider cards with search and filter chips. Right panel is a
|
||||||
|
* slot for future map integration.
|
||||||
|
*
|
||||||
|
* Uses radiogroup pattern for card selection — arrow keys navigate
|
||||||
|
* between cards, Space/Enter selects.
|
||||||
|
*
|
||||||
|
* Pure presentation component — props in, callbacks out.
|
||||||
|
*
|
||||||
|
* Spec: documentation/steps/steps/02_providers.yaml
|
||||||
|
*/
|
||||||
|
export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||||
|
providers,
|
||||||
|
selectedProviderId,
|
||||||
|
onSelectProvider,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSearch,
|
||||||
|
filters,
|
||||||
|
onFilterToggle,
|
||||||
|
onContinue,
|
||||||
|
onBack,
|
||||||
|
error,
|
||||||
|
loading = false,
|
||||||
|
mapPanel,
|
||||||
|
navigation,
|
||||||
|
isPrePlanning = false,
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const subheading = isPrePlanning
|
||||||
|
? 'Take your time exploring providers. You can always come back and choose a different one.'
|
||||||
|
: 'These providers are near your location. Each has their own packages and pricing.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardLayout
|
||||||
|
variant="list-map"
|
||||||
|
navigation={navigation}
|
||||||
|
showBackLink
|
||||||
|
backLabel="Back"
|
||||||
|
onBack={onBack}
|
||||||
|
sx={sx}
|
||||||
|
secondaryPanel={
|
||||||
|
mapPanel || (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'var(--fa-color-sage-50)',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Map coming soon
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
|
Choose a funeral provider
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{subheading}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
onSearch={onSearch}
|
||||||
|
placeholder="Search providers..."
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
{filters && filters.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||||
|
{filters.map((filter, index) => (
|
||||||
|
<Chip
|
||||||
|
key={filter.label}
|
||||||
|
label={filter.label}
|
||||||
|
selected={filter.active}
|
||||||
|
onClick={onFilterToggle ? () => onFilterToggle(index) : undefined}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mb: 2, display: 'block' }}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Showing results from {providers.length} provider{providers.length !== 1 ? 's' : ''}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<Typography variant="body2" color="error" sx={{ mb: 2 }} role="alert">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Provider list — radiogroup pattern */}
|
||||||
|
<Box
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Funeral providers"
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
||||||
|
>
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<ProviderCard
|
||||||
|
key={provider.id}
|
||||||
|
name={provider.name}
|
||||||
|
location={provider.location}
|
||||||
|
verified={provider.verified}
|
||||||
|
imageUrl={provider.imageUrl}
|
||||||
|
logoUrl={provider.logoUrl}
|
||||||
|
rating={provider.rating}
|
||||||
|
reviewCount={provider.reviewCount}
|
||||||
|
startingPrice={provider.startingPrice}
|
||||||
|
selected={selectedProviderId === provider.id}
|
||||||
|
onClick={() => onSelectProvider(provider.id)}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selectedProviderId === provider.id}
|
||||||
|
aria-label={`${provider.name}, ${provider.location}${provider.rating ? `, rated ${provider.rating}` : ''}${provider.startingPrice ? `, from $${provider.startingPrice}` : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{providers.length === 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No providers found matching your search.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Continue button */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
onClick={onContinue}
|
||||||
|
disabled={!selectedProviderId}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</WizardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProvidersStep.displayName = 'ProvidersStep';
|
||||||
|
export default ProvidersStep;
|
||||||
2
src/components/pages/ProvidersStep/index.ts
Normal file
2
src/components/pages/ProvidersStep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './ProvidersStep';
|
||||||
|
export * from './ProvidersStep';
|
||||||
Reference in New Issue
Block a user