From fa20599b672646b35a2ddf8593da23fb15859c41 Mon Sep 17 00:00:00 2001 From: Richie Date: Sun, 29 Mar 2026 14:36:27 +1100 Subject: [PATCH] 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) --- .../molecules/ProviderCard/ProviderCard.tsx | 10 +- .../ProvidersStep/ProvidersStep.stories.tsx | 302 ++++++++++++++++++ .../pages/ProvidersStep/ProvidersStep.tsx | 257 +++++++++++++++ src/components/pages/ProvidersStep/index.ts | 2 + 4 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 src/components/pages/ProvidersStep/ProvidersStep.stories.tsx create mode 100644 src/components/pages/ProvidersStep/ProvidersStep.tsx create mode 100644 src/components/pages/ProvidersStep/index.ts diff --git a/src/components/molecules/ProviderCard/ProviderCard.tsx b/src/components/molecules/ProviderCard/ProviderCard.tsx index 02146ff..98e8793 100644 --- a/src/components/molecules/ProviderCard/ProviderCard.tsx +++ b/src/components/molecules/ProviderCard/ProviderCard.tsx @@ -13,8 +13,8 @@ import { Typography } from '../../atoms/Typography'; // ─── Types ─────────────────────────────────────────────────────────────────── -/** Props for the FA ProviderCard molecule */ -export interface ProviderCardProps { +/** Own props for the FA ProviderCard molecule (excludes HTML/Card passthrough) */ +export interface ProviderCardOwnProps { /** Provider display name */ name: string; /** Location text (suburb, city) */ @@ -45,6 +45,10 @@ export interface ProviderCardProps { sx?: SxProps; } +/** Props for the FA ProviderCard molecule — includes HTML/ARIA passthrough to Card */ +export type ProviderCardProps = ProviderCardOwnProps & + Omit, keyof ProviderCardOwnProps>; + // ─── Constants ─────────────────────────────────────────────────────────────── const LOGO_SIZE = 'var(--fa-provider-card-logo-size)'; @@ -102,6 +106,7 @@ export const ProviderCard = React.forwardRef( startingPrice, onClick, sx, + ...rest }, ref, ) => { @@ -115,6 +120,7 @@ export const ProviderCard = React.forwardRef( selected={selected} padding="none" onClick={onClick} + {...rest} sx={[ { overflow: 'hidden', diff --git a/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx new file mode 100644 index 0000000..5cc0829 --- /dev/null +++ b/src/components/pages/ProvidersStep/ProvidersStep.stories.tsx @@ -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 = () => ( + + + + +); + +const nav = ( + } + 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 = { + title: 'Pages/ProvidersStep', + component: ProvidersStep, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Interactive (default) ────────────────────────────────────────────────── + +/** Fully interactive — search, filter, select a provider, continue */ +export const Default: Story = { + render: () => { + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState(defaultFilters); + const [error, setError] = useState(); + + 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 ( + { + 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('parsons'); + const [query, setQuery] = useState(''); + + return ( + alert('Continue')} + onBack={() => alert('Back')} + navigation={nav} + /> + ); + }, +}; + +// ─── Pre-planning variant ─────────────────────────────────────────────────── + +/** Pre-planning flow — softer copy */ +export const PrePlanning: Story = { + render: () => { + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + + return ( + alert('Continue')} + onBack={() => alert('Back')} + navigation={nav} + isPrePlanning + /> + ); + }, +}; + +// ─── Validation error ─────────────────────────────────────────────────────── + +/** No provider selected, error shown */ +export const WithError: Story = { + render: () => { + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + + return ( + {}} + 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 ( + {}} + 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 ( + {}} + 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(null); + const [query, setQuery] = useState(''); + + return ( + alert('Continue')} + onBack={() => alert('Back')} + navigation={nav} + /> + ); + }, +}; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx new file mode 100644 index 0000000..122e726 --- /dev/null +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -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; +} + +// ─── 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 = ({ + 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 ( + + + Map coming soon + + + ) + } + > + {/* Header */} + + Choose a funeral provider + + + {subheading} + + + {/* Search bar */} + + + + + {/* Filter chips */} + {filters && filters.length > 0 && ( + + {filters.map((filter, index) => ( + onFilterToggle(index) : undefined} + variant="outlined" + size="small" + /> + ))} + + )} + + {/* Results count */} + + Showing results from {providers.length} provider{providers.length !== 1 ? 's' : ''} + + + {/* Error message */} + {error && ( + + {error} + + )} + + {/* Provider list — radiogroup pattern */} + + {providers.map((provider) => ( + 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 && ( + + + No providers found matching your search. + + + )} + + + {/* Continue button */} + + + + + ); +}; + +ProvidersStep.displayName = 'ProvidersStep'; +export default ProvidersStep; diff --git a/src/components/pages/ProvidersStep/index.ts b/src/components/pages/ProvidersStep/index.ts new file mode 100644 index 0000000..cce2ac6 --- /dev/null +++ b/src/components/pages/ProvidersStep/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProvidersStep'; +export * from './ProvidersStep';