Files
Parsons/src/components/pages/ProvidersStep/ProvidersStep.tsx
Richie 1c3cdbc101 Batch 2: List-map layout rework — 420px fixed column, sticky headers
- WizardLayout ListMapLayout: 420px fixed left column (D-B), flex:1
  right panel, back link rendered inside left panel instead of above
  the split (eliminates gap above map)
- LAYOUT_MAP type updated to accept backLink prop for list-map variant
- ProvidersStep: heading + search + filters wrapped in sticky Box
  that pins at top of scrollable left panel while card list scrolls
- VenueStep: same sticky header treatment, heading moved inside form
  for consistent wrapper structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:18:52 +11:00

277 lines
8.4 KiB
TypeScript

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>
)
}
>
{/* Sticky header — stays pinned while card list scrolls */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
bgcolor: 'background.default',
pb: 1,
mx: -3,
px: 3,
}}
>
<Typography variant="display3" 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: 0, display: 'block' }}
aria-live="polite"
>
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
</Typography>
</Box>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
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" sx={{ mb: 1 }}>
No providers found matching your search.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting your search or clearing filters.
</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;