diff --git a/docs/memory/session-log.md b/docs/memory/session-log.md index d0e342a..0069050 100644 --- a/docs/memory/session-log.md +++ b/docs/memory/session-log.md @@ -26,6 +26,35 @@ Each entry follows this structure: ## Sessions +### Session 2026-03-29e — Feedback iteration Batches 1 & 2 + +**Agent(s):** Claude Opus 4.6 (1M context) + +**Work completed:** +- **Batch 1: Atom + Template Foundation** + - ToggleButtonGroup: label-to-options spacing `mb: 1→2`, top-align content `flex-start`, fixed selected border CSS specificity (added `&.Mui-selected` in grouped selector) + - Heading standardisation: all 6 split-layout steps changed from `h4` → `display3` (ProvidersStep, PackagesStep, PreviewStep, VenueStep, CoffinsStep, CoffinDetailsStep) per D-A + - DateTimeStep: normalised section gaps (scheduling fieldset `mb: 3→4`) + - CrematoriumStep: added subheading for consistency, normalised witness section `mb: 3→4` + - PackagesStep + DateTimeStep: fixed input label clipping (`pt: 0.5` on TextField containers) +- **Batch 2: List-Map Layout Rework (D-B)** + - WizardLayout ListMapLayout: 420px fixed left column, `flex: 1` right panel + - Back link rendered inside left panel (not above split) — eliminates gap above map + - LAYOUT_MAP type updated to accept `backLink` prop for list-map variant + - ProvidersStep + VenueStep: sticky header (heading + search + filters pinned at top of scrollable left panel) + +**Decisions made:** +- None new (implementing D-A heading standardisation and D-B list-map layout from previous session) + +**Open questions:** +- None + +**Next steps:** +- Batch 3: FilterPanel component (reusable Popover-based filter for providers, venues, coffins) +- Remaining batches 4–7 from iteration plan + +--- + ### Session 2026-03-29c — Grooming pass: critique/harden/polish all 15 steps **Agent(s):** Claude Opus 4.6 (1M context) diff --git a/src/components/molecules/FilterPanel/FilterPanel.stories.tsx b/src/components/molecules/FilterPanel/FilterPanel.stories.tsx new file mode 100644 index 0000000..e5d34ae --- /dev/null +++ b/src/components/molecules/FilterPanel/FilterPanel.stories.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { FilterPanel } from './FilterPanel'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import MenuItem from '@mui/material/MenuItem'; +import { Chip } from '../../atoms/Chip'; + +const meta: Meta = { + title: 'Molecules/FilterPanel', + component: FilterPanel, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + label: { control: 'text' }, + activeCount: { control: 'number' }, + minWidth: { control: 'number' }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Default state — no active filters */ +export const Default: Story = { + args: { + activeCount: 0, + children: ( + + + + + + ), + }, +}; + +/** With active filters — badge count shown */ +export const WithActiveFilters: Story = { + args: { + activeCount: 2, + onClear: () => {}, + children: ( + + + + + + ), + }, +}; + +/** Select-based filters — category + price (CoffinsStep pattern) */ +export const SelectFilters: Story = { + args: { + activeCount: 1, + onClear: () => {}, + minWidth: 300, + children: ( + <> + + All categories + Solid Timber + Environmental + Designer + + + All prices + Under $2,000 + $2,000 – $4,000 + Over $4,000 + + + ), + }, +}; + +/** Custom label */ +export const CustomLabel: Story = { + args: { + label: 'Sort & Filter', + activeCount: 0, + children: ( + + Most popular + Price: Low to high + Price: High to low + + ), + }, +}; diff --git a/src/components/molecules/FilterPanel/FilterPanel.tsx b/src/components/molecules/FilterPanel/FilterPanel.tsx new file mode 100644 index 0000000..9c18cae --- /dev/null +++ b/src/components/molecules/FilterPanel/FilterPanel.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Popover from '@mui/material/Popover'; +import TuneIcon from '@mui/icons-material/Tune'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { Button } from '../../atoms/Button'; +import { Badge } from '../../atoms/Badge'; +import { Typography } from '../../atoms/Typography'; +import { Link } from '../../atoms/Link'; +import { Divider } from '../../atoms/Divider'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** Props for the FilterPanel molecule */ +export interface FilterPanelProps { + /** Trigger button label */ + label?: string; + /** Number of active filters (shown as count on the trigger) */ + activeCount?: number; + /** Filter controls — rendered inside the Popover body */ + children: React.ReactNode; + /** Callback when "Clear all" is clicked */ + onClear?: () => void; + /** Popover min-width */ + minWidth?: number; + /** MUI sx prop for the trigger button */ + sx?: SxProps; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Reusable filter panel for the FA arrangement wizard. + * + * Renders a trigger button ("Filters") that opens a Popover containing + * arbitrary filter controls (chips, selects, sliders, etc.) passed as + * children. Active filter count shown as a badge on the trigger. + * + * D-C: Popover for desktop MVP. Mobile Drawer variant planned for later. + * + * Used in ProvidersStep, VenueStep, and CoffinsStep. + * + * Usage: + * ```tsx + * + * + * + * + * ``` + */ +export const FilterPanel: React.FC = ({ + label = 'Filters', + activeCount = 0, + children, + onClear, + minWidth = 280, + sx, +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const popoverId = open ? 'filter-panel-popover' : undefined; + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + {/* Trigger button */} + + + + + {/* Popover panel */} + + {/* Header */} + + Filters + {onClear && activeCount > 0 && ( + { + onClear(); + }} + underline="hover" + sx={{ fontSize: '0.8125rem' }} + > + Clear all + + )} + + + + + {/* Filter controls */} + + {children} + + + + + {/* Footer — done button */} + + + + + + ); +}; + +FilterPanel.displayName = 'FilterPanel'; +export default FilterPanel; diff --git a/src/components/molecules/FilterPanel/index.ts b/src/components/molecules/FilterPanel/index.ts new file mode 100644 index 0000000..ebe8545 --- /dev/null +++ b/src/components/molecules/FilterPanel/index.ts @@ -0,0 +1,3 @@ +export { FilterPanel } from './FilterPanel'; +export type { FilterPanelProps } from './FilterPanel'; +export { default } from './FilterPanel'; diff --git a/src/components/pages/CoffinsStep/CoffinsStep.tsx b/src/components/pages/CoffinsStep/CoffinsStep.tsx index 37bff2f..e17b6ca 100644 --- a/src/components/pages/CoffinsStep/CoffinsStep.tsx +++ b/src/components/pages/CoffinsStep/CoffinsStep.tsx @@ -5,6 +5,7 @@ import MenuItem from '@mui/material/MenuItem'; import Pagination from '@mui/material/Pagination'; import type { SxProps, Theme } from '@mui/material/styles'; import { WizardLayout } from '../../templates/WizardLayout'; +import { FilterPanel } from '../../molecules/FilterPanel'; import { Card } from '../../atoms/Card'; import { Badge } from '../../atoms/Badge'; import { Typography } from '../../atoms/Typography'; @@ -142,223 +143,20 @@ export const CoffinsStep: React.FC = ({ sx, }) => { const displayCount = totalCount ?? coffins.length; + const activeFilterCount = + (values.categoryFilter !== 'all' ? 1 : 0) + (values.priceFilter !== 'all' ? 1 : 0); const handleFilterChange = (field: 'categoryFilter' | 'priceFilter', value: string) => { onChange({ ...values, [field]: value, page: 1 }); }; - // ─── Sidebar content (filters) ─── - const sidebar = ( - - - Filters - - - - handleFilterChange('categoryFilter', e.target.value)} - fullWidth - > - {categories.map((cat) => ( - - {cat.label} - - ))} - - - handleFilterChange('priceFilter', e.target.value)} - fullWidth - > - {priceRanges.map((range) => ( - - {range.label} - - ))} - - - - ); - - // ─── Main content (card grid) ─── - const mainContent = ( - { - e.preventDefault(); - if (!loading) onContinue(); - }} - > - {/* Page heading */} - - Choose a coffin - - - - {isPrePlanning - ? 'Browse the range to get an idea of styles and pricing. You can change your selection later.' - : 'Browse the range available with your selected provider. Use the filters to narrow your options.'} - - - - Selecting a coffin within your package allowance won't change your total. Coffins - outside the allowance will adjust the price. - - - {/* Results count */} - - Showing {displayCount} coffin{displayCount !== 1 ? 's' : ''} - - - {/* Coffin card grid */} - - {coffins.map((coffin, index) => ( - onChange({ ...values, selectedCoffinId: coffin.id })} - role="radio" - aria-checked={coffin.id === values.selectedCoffinId} - tabIndex={ - values.selectedCoffinId === null - ? index === 0 - ? 0 - : -1 - : coffin.id === values.selectedCoffinId - ? 0 - : -1 - } - sx={{ overflow: 'hidden' }} - > - {/* Image */} - - {coffin.isPopular && ( - - - Most Popular - - - )} - - - {/* Content */} - - - {coffin.name} - - - {coffin.category} - - - ${coffin.price.toLocaleString('en-AU')} - - - - ))} - - {coffins.length === 0 && ( - - - No coffins match your selected filters. - - - Try adjusting the category or price range. - - - )} - - - {/* Validation error */} - {errors?.selectedCoffinId && ( - - {errors.selectedCoffinId} - - )} - - {/* Pagination */} - {totalPages > 1 && ( - - onChange({ ...values, page })} - color="primary" - /> - - )} - - - - {/* CTAs */} - - {onSaveAndExit ? ( - - ) : ( - - )} - - - - ); + const handleFilterClear = () => { + onChange({ ...values, categoryFilter: 'all', priceFilter: 'all', page: 1 }); + }; return ( = ({ onBack={onBack} hideHelpBar={hideHelpBar} sx={sx} - secondaryPanel={mainContent} > - {sidebar} + { + e.preventDefault(); + if (!loading) onContinue(); + }} + > + {/* Page heading */} + + Choose a coffin + + + + {isPrePlanning + ? 'Browse the range to get an idea of styles and pricing. You can change your selection later.' + : 'Browse the range available with your selected provider. Use the filters to narrow your options.'} + + + + Selecting a coffin within your package allowance won't change your total. Coffins + outside the allowance will adjust the price. + + + {/* Filter button + results count */} + + + handleFilterChange('categoryFilter', e.target.value)} + fullWidth + > + {categories.map((cat) => ( + + {cat.label} + + ))} + + + handleFilterChange('priceFilter', e.target.value)} + fullWidth + > + {priceRanges.map((range) => ( + + {range.label} + + ))} + + + + + Showing {displayCount} coffin{displayCount !== 1 ? 's' : ''} + + + + {/* Coffin card grid — full width (D-F) */} + + {coffins.map((coffin, index) => ( + onChange({ ...values, selectedCoffinId: coffin.id })} + role="radio" + aria-checked={coffin.id === values.selectedCoffinId} + tabIndex={ + values.selectedCoffinId === null + ? index === 0 + ? 0 + : -1 + : coffin.id === values.selectedCoffinId + ? 0 + : -1 + } + sx={{ overflow: 'hidden' }} + > + {/* Image */} + + {coffin.isPopular && ( + + + Most Popular + + + )} + + + {/* Content */} + + + {coffin.name} + + + {coffin.category} + + + ${coffin.price.toLocaleString('en-AU')} + + + + ))} + + {coffins.length === 0 && ( + + + No coffins match your selected filters. + + + Try adjusting the category or price range. + + + )} + + + {/* Validation error */} + {errors?.selectedCoffinId && ( + + {errors.selectedCoffinId} + + )} + + {/* Pagination */} + {totalPages > 1 && ( + + onChange({ ...values, page })} + color="primary" + /> + + )} + + + + {/* CTAs */} + + {onSaveAndExit ? ( + + ) : ( + + )} + + + ); }; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index 64b5f24..960d560 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -4,6 +4,7 @@ import type { SxProps, Theme } from '@mui/material/styles'; import { WizardLayout } from '../../templates/WizardLayout'; import { ProviderCard } from '../../molecules/ProviderCard'; import { SearchBar } from '../../molecules/SearchBar'; +import { FilterPanel } from '../../molecules/FilterPanel'; import { Chip } from '../../atoms/Chip'; import { Typography } from '../../atoms/Typography'; import { Button } from '../../atoms/Button'; @@ -62,6 +63,8 @@ export interface ProvidersStepProps { filters?: ProviderFilter[]; /** Callback when a filter chip is toggled */ onFilterToggle?: (index: number) => void; + /** Callback to clear all filters */ + onFilterClear?: () => void; /** Callback for the Continue button */ onContinue: () => void; /** Callback for the Back button */ @@ -105,6 +108,7 @@ export const ProvidersStep: React.FC = ({ onSearch, filters, onFilterToggle, + onFilterClear, onContinue, onBack, error, @@ -165,32 +169,37 @@ export const ProvidersStep: React.FC = ({ {subheading} - {/* Search bar */} - - - - - {/* Filter chips */} - {filters && filters.length > 0 && ( - - {filters.map((filter, index) => ( - onFilterToggle(index) : undefined} - variant="outlined" - size="small" - /> - ))} + {/* Search bar + filter button */} + + + - )} + {filters && filters.length > 0 && ( + f.active).length} + onClear={onFilterClear} + > + + {filters.map((filter, index) => ( + onFilterToggle(index) : undefined} + variant="outlined" + size="small" + /> + ))} + + + )} + {/* Results count */} ; + /** Callback to clear all filters */ + onFilterClear?: () => void; /** Location name for the results count */ locationName?: string; /** Whether this is a pre-planning flow */ @@ -130,6 +133,7 @@ export const VenueStep: React.FC = ({ { key: 'features', label: 'Venue Features' }, { key: 'religion', label: 'Religion' }, ], + onFilterClear, locationName, isPrePlanning = false, mapPanel, @@ -224,8 +228,8 @@ export const VenueStep: React.FC = ({ : 'Choose a venue for the funeral service. You can filter by location, features, and religion.'} - {/* ─── Search + Filters ─── */} - + {/* ─── Search + Filter button ─── */} + = ({ ), }} - sx={{ mb: 2 }} /> - - - {filterOptions.map((filter) => ( - handleFilterToggle(filter.key)} - selected={values.activeFilters.includes(filter.key)} - /> - ))} - + + + {filterOptions.map((filter) => ( + handleFilterToggle(filter.key)} + selected={values.activeFilters.includes(filter.key)} + /> + ))} + + {/* ─── Results count ─── */} diff --git a/src/components/templates/WizardLayout/WizardLayout.tsx b/src/components/templates/WizardLayout/WizardLayout.tsx index 3a28bde..240b9c7 100644 --- a/src/components/templates/WizardLayout/WizardLayout.tsx +++ b/src/components/templates/WizardLayout/WizardLayout.tsx @@ -9,9 +9,10 @@ import { Typography } from '../../atoms/Typography'; // ─── Types ─────────────────────────────────────────────────────────────────── -/** Layout variant matching the 5 wizard page templates */ +/** Layout variant matching the wizard page templates */ export type WizardLayoutVariant = | 'centered-form' + | 'wide-form' | 'list-map' | 'list-detail' | 'grid-sidebar' @@ -142,6 +143,20 @@ const CenteredFormLayout: React.FC<{ children: React.ReactNode }> = ({ children ); +/** Wide Form: single column maxWidth "lg", for card grids (coffins, etc.) */ +const WideFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + /** List + Map: 420px fixed scrollable list (left) / flex map (right) — D-B */ const ListMapLayout: React.FC<{ children: React.ReactNode; @@ -256,6 +271,7 @@ const LAYOUT_MAP: Record< }> > = { 'centered-form': CenteredFormLayout, + 'wide-form': WideFormLayout, 'list-map': ListMapLayout, 'list-detail': ListDetailLayout, 'grid-sidebar': GridSidebarLayout, @@ -263,7 +279,7 @@ const LAYOUT_MAP: Record< }; /** Variants that show the stepper/total bar */ -const STEPPER_VARIANTS: WizardLayoutVariant[] = ['grid-sidebar', 'detail-toggles']; +const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'detail-toggles']; // ─── Component ───────────────────────────────────────────────────────────────