Add FuneralFinder v3 — clean form with status cards + glassmorphism
- Two side-by-side StatusCards (Immediate Need / Pre-planning) with warm fills - Glassmorphism container (backdrop-blur, semi-transparent white, deep shadow) - Overline section labels, warm tonal field backgrounds (brand-100, no border) - Funeral type Select + location Input with pin icon, no focus ring per design - CTA always active — validates on click, scrolls to first missing field - WAI-ARIA roving tabindex on radiogroup, aria-labelledby via useId() - Semantic tokens throughout (border-brand, surface-warm, text-brand, etc.) - Critique: 33/40 (Good), Audit: 18/20 (Excellent) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ duplicates) and MUST update it after completing one.
|
||||
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). |
|
||||
| FuneralFinder | done | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Hero search widget v1. Full stepped conversational flow: Intent → Planning For (conditional) → Funeral Type + preferences (optional themes) → Service preference (conditional, auto-advance) → Location + CTA (always visible). Smart defaults — minimum search: intent + location. Types: Cremation, Burial, Water Burial (QLD only), Explore All. Service: With/No/Flexible. Themes: eco-friendly, budget-friendly, religious specialisation. Loading prop, location validation. Audit: 14/20 (Good). Critique: 29/40 (Good). Future: progress indicator, roving tabindex, location autocomplete. |
|
||||
| FuneralFinderV2 | done | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Hero search widget v2 — quick-form approach. 4-step vertical form with numbered circles (48px, brand-200 default → brand-500 completed) and connector lines. Steps: (1) Intent — 3 options, (2) Planning for — conditional auto-set for arrange-now, (3) Funeral type — 5 options, (4) Location — text input. Sequential unlock: each step enables when previous is filled. Display serif heading + subheading with divider. CTA disabled until location filled. Trust signal below CTA. Critique: 33/40 (Good). Audit: 18/20 (Excellent). |
|
||||
| FuneralFinderV3 | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | Hero search widget v3 — clean form with status cards. Glassmorphism container (backdrop-blur, semi-transparent white). Two side-by-side tappable StatusCards (Immediate Need / Pre-planning) with warm tonal fills. Funeral type Select + location OutlinedInput with location pin icon — both use warm brand-100 fill, no border, no focus ring (per design). Overline section labels (brand-800). CTA always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). |
|
||||
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
||||
| Footer | done | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
||||
|
||||
@@ -23,6 +23,49 @@ Each entry follows this structure:
|
||||
|
||||
## Sessions
|
||||
|
||||
### Session 2026-03-26f — FuneralFinder v3 build
|
||||
|
||||
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||
|
||||
**Work completed:**
|
||||
- Created FuneralFinderV3 — clean vertical form approach based on user's Figma mockup (5919:29445)
|
||||
- Two side-by-side StatusCards (Immediate Need / Pre-planning) replace V2's dropdown + step circles
|
||||
- Glassmorphism container (backdrop-blur, semi-transparent white, warm-tinted border, deep shadow)
|
||||
- Overline section labels (uppercase, brand-800) for "Current Status", "Funeral Type", "Location"
|
||||
- Warm tonal field backgrounds (brand-100) with no visible border, no focus ring (per user request)
|
||||
- Location input with location pin icon (LocationOnOutlined)
|
||||
- CTA always active — validates on click, scrolls to first missing field (status or location)
|
||||
- Funeral type options: same as V2 + "Show all options"
|
||||
- WAI-ARIA roving tabindex on radiogroup (arrow-key navigation between status cards)
|
||||
- aria-labelledby via React.useId() for all fields
|
||||
- Semantic token usage: border-brand, surface-warm, text-brand, interactive-focus, text-disabled
|
||||
- Error messages conditionally rendered in aria-live regions (copper/brand tone, not red — gentle validation)
|
||||
- First pass scored Critique 33/40, Audit 13/20
|
||||
- Iterated on all P1-P3 findings, re-audit scored 18/20
|
||||
|
||||
**Decisions made:**
|
||||
- Status cards replace V2's step-circle + dropdown pattern — simpler, more visual, side-by-side on desktop
|
||||
- Glassmorphism is a V3 differentiator — works over hero images, degrades gracefully to opaque white
|
||||
- No sequential unlock — all fields accessible immediately (V2 locked steps until previous was filled)
|
||||
- CTA always active (V2 disabled CTA until location was filled) — validates on click, scrolls to missing field
|
||||
- Form simplified to 3 fields (status, funeral type, location) vs V2's 4 (intent, planning-for, type, location)
|
||||
- "Planning for" folded into StatusCard descriptions rather than separate field
|
||||
- Focus ring suppressed on Select/Input per user requirement — status cards retain focus-visible
|
||||
- Error colour uses text.brand (copper) not feedback.error (red) — intentional gentle tone for funeral context
|
||||
- Warm brand-tinted CTA shadow (brand.600 at 20% opacity) — no token for coloured shadows, commented
|
||||
|
||||
**Open questions:**
|
||||
- User to review V3 in Storybook — compare against V1 and V2
|
||||
- Location autocomplete integration still pending across all versions
|
||||
- Consider adding component tokens for the glassmorphism treatment if reused elsewhere
|
||||
|
||||
**Next steps:**
|
||||
- User review of V3 in Storybook
|
||||
- Decision: V1 vs V2 vs V3 for production
|
||||
- If V3 chosen: location autocomplete, possible mobile refinements
|
||||
|
||||
---
|
||||
|
||||
### Session 2026-03-26e — FuneralFinder v2 polish + consistency fixes
|
||||
|
||||
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { FuneralFinderV3 } from './FuneralFinderV3';
|
||||
|
||||
const meta: Meta<typeof FuneralFinderV3> = {
|
||||
title: 'Organisms/FuneralFinderV3',
|
||||
component: FuneralFinderV3,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
args: {
|
||||
onSearch: (params) => {
|
||||
console.log('Search params:', params);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FuneralFinderV3>;
|
||||
|
||||
/** Default empty state — all fields ready for input */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Loading state — CTA shows spinner */
|
||||
export const Loading: Story = {
|
||||
args: { loading: true },
|
||||
};
|
||||
|
||||
/** Over a hero image — demonstrates the glassmorphism effect */
|
||||
export const OnHeroImage: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background:
|
||||
'linear-gradient(135deg, #2C2E35 0%, #4C5B6B 40%, #8EA2A7 70%, #D8C3B5 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: 480, width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
/** Below a masthead — overlapping hero section */
|
||||
export const BelowMasthead: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(160deg, #2C2E35 0%, #4C5B6B 60%, #6B3C13 100%)',
|
||||
color: '#fff',
|
||||
py: 8,
|
||||
px: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--fa-font-family-display)',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Funeral Arranger
|
||||
</Box>
|
||||
<Box sx={{ opacity: 0.8, fontSize: '1.125rem' }}>
|
||||
Find trusted funeral directors near you
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
mx: 'auto',
|
||||
mt: -5,
|
||||
px: 2,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Constrained width — typical sidebar or narrow column */
|
||||
export const Narrow: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 380, mx: 'auto' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
578
src/components/organisms/FuneralFinder/FuneralFinderV3.tsx
Normal file
578
src/components/organisms/FuneralFinder/FuneralFinderV3.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Select, { type SelectChangeEvent } from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Status = 'immediate' | 'preplanning';
|
||||
type FuneralType =
|
||||
| 'cremation-funeral'
|
||||
| 'cremation-only'
|
||||
| 'burial-funeral'
|
||||
| 'graveside-only'
|
||||
| 'water-cremation'
|
||||
| 'show-all';
|
||||
|
||||
/** Search parameters returned on form submission */
|
||||
export interface FuneralFinderV3SearchParams {
|
||||
/** User's current situation */
|
||||
status: Status;
|
||||
/** Type of funeral selected (defaults to show-all if not chosen) */
|
||||
funeralType: FuneralType;
|
||||
/** Suburb or postcode */
|
||||
location: string;
|
||||
}
|
||||
|
||||
/** Props for the FuneralFinder v3 organism */
|
||||
export interface FuneralFinderV3Props {
|
||||
/** Called when the user submits with valid data */
|
||||
onSearch?: (params: FuneralFinderV3SearchParams) => void;
|
||||
/** Shows loading state on the CTA */
|
||||
loading?: boolean;
|
||||
/** Optional heading override */
|
||||
heading?: string;
|
||||
/** Optional subheading override */
|
||||
subheading?: string;
|
||||
/** MUI sx override for the root container */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Options ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_OPTIONS: { key: Status; title: string; description: string }[] = [
|
||||
{
|
||||
key: 'immediate',
|
||||
title: 'Immediate Need',
|
||||
description: 'For a recent or expected loss',
|
||||
},
|
||||
{
|
||||
key: 'preplanning',
|
||||
title: 'Pre-planning',
|
||||
description: 'For yourself or a loved one',
|
||||
},
|
||||
];
|
||||
|
||||
const FUNERAL_TYPE_OPTIONS: { value: FuneralType; label: string }[] = [
|
||||
{ value: 'cremation-funeral', label: 'Cremation with funeral' },
|
||||
{
|
||||
value: 'cremation-only',
|
||||
label: 'Cremation only (no funeral, no attendance)',
|
||||
},
|
||||
{ value: 'burial-funeral', label: 'Burial with funeral' },
|
||||
{ value: 'graveside-only', label: 'Graveside burial only' },
|
||||
{ value: 'water-cremation', label: 'Water cremation (QLD only)' },
|
||||
{ value: 'show-all', label: 'Show all options' },
|
||||
];
|
||||
|
||||
/** Hoisted outside component to avoid re-creation on render */
|
||||
const selectPlaceholder = (
|
||||
<span style={{ color: 'var(--fa-color-text-disabled)' }}>
|
||||
Select funeral type
|
||||
</span>
|
||||
);
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Uppercase section label — overline style */
|
||||
function SectionLabel({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<Typography
|
||||
variant="overline"
|
||||
component="div"
|
||||
id={id}
|
||||
sx={{ color: 'var(--fa-color-brand-800, #6B3C13)' }}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tappable status card with roving tabindex support for radiogroup pattern */
|
||||
const StatusCard = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
tabIndex?: number;
|
||||
onKeyDown?: React.KeyboardEventHandler;
|
||||
}
|
||||
>(({ title, description, selected, onClick, tabIndex, onKeyDown }, ref) => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
tabIndex={tabIndex}
|
||||
onKeyDown={onKeyDown}
|
||||
sx={{
|
||||
width: '100%',
|
||||
px: 2.5,
|
||||
py: 2.5,
|
||||
border: '2px solid',
|
||||
borderColor: selected
|
||||
? 'var(--fa-color-border-brand, #BA834E)'
|
||||
: 'transparent',
|
||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
||||
bgcolor: selected
|
||||
? 'var(--fa-color-surface-warm, #FEF9F5)'
|
||||
: 'var(--fa-color-brand-100, #F7ECDF)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center',
|
||||
transition:
|
||||
'border-color 200ms ease, background-color 200ms ease, transform 100ms ease',
|
||||
'&:hover': {
|
||||
borderColor: selected
|
||||
? 'var(--fa-color-border-brand, #BA834E)'
|
||||
: 'var(--fa-color-brand-300, #D8C3B5)',
|
||||
bgcolor: selected
|
||||
? 'var(--fa-color-surface-warm, #FEF9F5)'
|
||||
: 'var(--fa-color-brand-200, #EBDAC8)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid var(--fa-color-interactive-focus, #BA834E)',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="span"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
mb: 0.75,
|
||||
color: selected
|
||||
? 'var(--fa-color-text-brand, #B0610F)'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: 'var(--fa-color-brand-800, #6B3C13)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
));
|
||||
StatusCard.displayName = 'StatusCard';
|
||||
|
||||
// ─── Shared field styles ────────────────────────────────────────────────────
|
||||
|
||||
/** Warm tonal fill, no visible border, no focus ring (per design spec) */
|
||||
const fieldBaseSx = {
|
||||
width: '100%',
|
||||
bgcolor: 'var(--fa-color-brand-100, #F7ECDF)',
|
||||
borderRadius: 'var(--fa-border-radius-md, 8px)',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
const fieldInputStyles = {
|
||||
py: '14px',
|
||||
px: 2,
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
};
|
||||
|
||||
const selectMenuProps = {
|
||||
PaperProps: {
|
||||
sx: {
|
||||
mt: 0.5,
|
||||
borderRadius: 'var(--fa-border-radius-md, 8px)',
|
||||
boxShadow: 'var(--fa-shadow-md)',
|
||||
'& .MuiMenuItem-root': {
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
minHeight: 44,
|
||||
fontSize: '0.9375rem',
|
||||
fontFamily: 'var(--fa-font-family-body)',
|
||||
whiteSpace: 'normal' as const,
|
||||
'&:hover': { bgcolor: 'var(--fa-color-surface-warm)' },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'var(--fa-color-surface-warm)',
|
||||
fontWeight: 600,
|
||||
'&:hover': { bgcolor: 'var(--fa-color-surface-warm)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hero search widget v3 — clean vertical form with status cards.
|
||||
*
|
||||
* Two tappable status cards (Immediate Need / Pre-planning), a funeral type
|
||||
* dropdown, a location input, and a CTA. Glassmorphism container with warm
|
||||
* tonal field backgrounds. Overline section labels. CTA is always active —
|
||||
* clicking it with missing required fields scrolls to the first gap.
|
||||
*
|
||||
* Required fields: status + location (min 3 chars).
|
||||
* Funeral type defaults to "show all" if not selected.
|
||||
*/
|
||||
export const FuneralFinderV3 = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FuneralFinderV3Props
|
||||
>((props, ref) => {
|
||||
const {
|
||||
onSearch,
|
||||
loading = false,
|
||||
heading = 'Find funeral directors near you',
|
||||
subheading =
|
||||
"Tell us what you need and we\u2019ll show options in your area.",
|
||||
sx,
|
||||
} = props;
|
||||
|
||||
// ─── IDs for aria-labelledby ──────────────────────────────
|
||||
const id = React.useId();
|
||||
const statusLabelId = `${id}-status`;
|
||||
const funeralTypeLabelId = `${id}-funeral-type`;
|
||||
const locationLabelId = `${id}-location`;
|
||||
|
||||
// ─── State ───────────────────────────────────────────────
|
||||
const [status, setStatus] = React.useState<Status | ''>('');
|
||||
const [funeralType, setFuneralType] = React.useState<FuneralType | ''>('');
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [errors, setErrors] = React.useState<{
|
||||
status?: boolean;
|
||||
location?: boolean;
|
||||
}>({});
|
||||
|
||||
// ─── Refs ────────────────────────────────────────────────
|
||||
const statusSectionRef = React.useRef<HTMLDivElement>(null);
|
||||
const locationSectionRef = React.useRef<HTMLDivElement>(null);
|
||||
const locationInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cardRefs = React.useRef<(HTMLButtonElement | null)[]>([null, null]);
|
||||
|
||||
// ─── Clear errors as fields are filled ───────────────────
|
||||
const prevStatus = React.useRef(status);
|
||||
React.useEffect(() => {
|
||||
if (status !== prevStatus.current) {
|
||||
prevStatus.current = status;
|
||||
if (status && errors.status) {
|
||||
setErrors((prev) => ({ ...prev, status: false }));
|
||||
}
|
||||
}
|
||||
}, [status, errors.status]);
|
||||
|
||||
const prevLocation = React.useRef(location);
|
||||
React.useEffect(() => {
|
||||
if (location !== prevLocation.current) {
|
||||
prevLocation.current = location;
|
||||
if (location.trim().length >= 3 && errors.location) {
|
||||
setErrors((prev) => ({ ...prev, location: false }));
|
||||
}
|
||||
}
|
||||
}, [location, errors.location]);
|
||||
|
||||
// ─── Radiogroup keyboard nav (WAI-ARIA pattern) ──────────
|
||||
const activeStatusIndex = status
|
||||
? STATUS_OPTIONS.findIndex((o) => o.key === status)
|
||||
: 0;
|
||||
|
||||
const handleStatusKeyDown = (e: React.KeyboardEvent) => {
|
||||
const isNext = e.key === 'ArrowRight' || e.key === 'ArrowDown';
|
||||
const isPrev = e.key === 'ArrowLeft' || e.key === 'ArrowUp';
|
||||
if (!isNext && !isPrev) return;
|
||||
e.preventDefault();
|
||||
const current = cardRefs.current.indexOf(
|
||||
e.target as HTMLButtonElement,
|
||||
);
|
||||
if (current === -1) return;
|
||||
const next = isNext
|
||||
? Math.min(current + 1, STATUS_OPTIONS.length - 1)
|
||||
: Math.max(current - 1, 0);
|
||||
if (next !== current) {
|
||||
cardRefs.current[next]?.focus();
|
||||
setStatus(STATUS_OPTIONS[next].key);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────
|
||||
const handleFuneralType = (e: SelectChangeEvent<string>) => {
|
||||
setFuneralType(e.target.value as FuneralType);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!status) {
|
||||
setErrors({ status: true });
|
||||
statusSectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (location.trim().length < 3) {
|
||||
setErrors({ location: true });
|
||||
locationSectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
locationInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
onSearch?.({
|
||||
status,
|
||||
funeralType: funeralType || 'show-all',
|
||||
location: location.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="search"
|
||||
aria-label="Find funeral directors"
|
||||
sx={[
|
||||
{
|
||||
// Glassmorphism — semi-transparent for backdrop-blur effect.
|
||||
// No token equivalent for translucent white or warm-tinted border.
|
||||
bgcolor: 'rgba(255, 255, 255, 0.92)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(213, 195, 182, 0.15)',
|
||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
||||
boxShadow: 'var(--fa-shadow-xl)',
|
||||
px: { xs: 3.5, sm: 5 },
|
||||
py: { xs: 4, sm: 5 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* ── Header ──────────────────────────────────────────── */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontFamily: 'var(--fa-font-family-display)',
|
||||
fontWeight: 400,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{heading}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subheading}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Current Status ──────────────────────────────────── */}
|
||||
<Box ref={statusSectionRef}>
|
||||
<SectionLabel id={statusLabelId}>Current Status</SectionLabel>
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-labelledby={statusLabelId}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
|
||||
gap: 2,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt, i) => (
|
||||
<StatusCard
|
||||
key={opt.key}
|
||||
ref={(el) => {
|
||||
cardRefs.current[i] = el;
|
||||
}}
|
||||
title={opt.title}
|
||||
description={opt.description}
|
||||
selected={status === opt.key}
|
||||
onClick={() => setStatus(opt.key)}
|
||||
tabIndex={i === activeStatusIndex ? 0 : -1}
|
||||
onKeyDown={handleStatusKeyDown}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ minHeight: '1.5rem', mt: 1, textAlign: 'center' }}
|
||||
aria-live="polite"
|
||||
>
|
||||
{errors.status && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
role="alert"
|
||||
sx={{ color: 'var(--fa-color-text-brand, #B0610F)' }}
|
||||
>
|
||||
Please select your current situation
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Funeral Type ────────────────────────────────────── */}
|
||||
<Box>
|
||||
<SectionLabel id={funeralTypeLabelId}>Funeral Type</SectionLabel>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Select
|
||||
value={funeralType}
|
||||
onChange={handleFuneralType}
|
||||
displayEmpty
|
||||
renderValue={(v) =>
|
||||
v
|
||||
? FUNERAL_TYPE_OPTIONS.find((o) => o.value === v)?.label
|
||||
: selectPlaceholder
|
||||
}
|
||||
MenuProps={selectMenuProps}
|
||||
sx={{
|
||||
...fieldBaseSx,
|
||||
'& .MuiSelect-select': {
|
||||
...fieldInputStyles,
|
||||
minHeight: 'unset !important',
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
right: 12,
|
||||
},
|
||||
}}
|
||||
inputProps={{ 'aria-labelledby': funeralTypeLabelId }}
|
||||
>
|
||||
{FUNERAL_TYPE_OPTIONS.map((o) => (
|
||||
<MenuItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── Location ────────────────────────────────────────── */}
|
||||
<Box ref={locationSectionRef}>
|
||||
<SectionLabel id={locationLabelId}>Location</SectionLabel>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<OutlinedInput
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Enter suburb or postcode"
|
||||
inputRef={locationInputRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
}}
|
||||
/>
|
||||
</InputAdornment>
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmit();
|
||||
}}
|
||||
sx={{
|
||||
...fieldBaseSx,
|
||||
'& .MuiOutlinedInput-input': {
|
||||
...fieldInputStyles,
|
||||
'&::placeholder': {
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
'aria-labelledby': locationLabelId,
|
||||
'aria-required': true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ minHeight: '1.5rem', mt: 1 }}
|
||||
aria-live="polite"
|
||||
>
|
||||
{errors.location && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
role="alert"
|
||||
sx={{ color: 'var(--fa-color-text-brand, #B0610F)' }}
|
||||
>
|
||||
Please enter a suburb or postcode
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── CTA ─────────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
||||
onClick={handleSubmit}
|
||||
sx={{
|
||||
minHeight: 52,
|
||||
// Warm brand-tinted shadow — uses brand.600 (#B0610F / rgb 176,97,15)
|
||||
// at low opacity. No token for coloured shadows.
|
||||
boxShadow:
|
||||
'0 10px 15px -3px rgba(176, 97, 15, 0.2), 0 4px 6px -4px rgba(176, 97, 15, 0.15)',
|
||||
'&:hover': {
|
||||
boxShadow:
|
||||
'0 10px 20px -3px rgba(176, 97, 15, 0.3), 0 4px 8px -4px rgba(176, 97, 15, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Find Funeral Directors
|
||||
</Button>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||
>
|
||||
Free to use · No obligation
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
FuneralFinderV3.displayName = 'FuneralFinderV3';
|
||||
export default FuneralFinderV3;
|
||||
@@ -10,3 +10,9 @@ export {
|
||||
type FuneralFinderV2Props,
|
||||
type FuneralFinderV2SearchParams,
|
||||
} from './FuneralFinderV2';
|
||||
|
||||
export {
|
||||
FuneralFinderV3,
|
||||
type FuneralFinderV3Props,
|
||||
type FuneralFinderV3SearchParams,
|
||||
} from './FuneralFinderV3';
|
||||
|
||||
Reference in New Issue
Block a user