Compare commits

..

43 Commits

Author SHA1 Message Date
3d248d1197 Strip AI tooling and working docs for dev push 2026-04-23 14:24:48 +10:00
826f888e87 Memory: session-log 2026-04-23c + registry catch-up; retire NearbyPackageCard
Session log entry for today (2026-04-23c) covers the 8-commit demo
update pass: MiniCard-based nearby-verified grid, package fixtures
cleanup (drop 'unknown' treatment) + 10 new verified packages, Vite
envDir fix for Google Maps, always-recommended Comparison route,
responsive PackageDetail CTAs, ComparisonTable tier-aware Unknown
rendering, and info-card un-stick.

Plus the pre-drafted 2026-04-23b session-log entry (extractions,
CompareBar pattern, PackageDetail toggle, basket persistence) and the
matching component-registry updates for those prior-session changes.

Registry today: NearbyPackageCard row removed (now orphaned and deleted
— no consumers after MiniCard swap). PackagesStep, PackageDetail, and
ComparisonTable rows updated for this session's changes.

Decisions-log D052–D056 live locally; decisions-log.md is gitignored so
those additions aren't in this commit. Flagging the gitignore mismatch
— the session log references decision IDs that won't resolve for anyone
pulling from the remote until that's sorted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:23:45 +10:00
a3d4427190 ComparisonTable: un-stick the info card so it scrolls with columns
The top-left "Package Comparison" info card was sticky-left, originally
to mirror the row-label column. On horizontal scroll it pinned over the
leftmost package column — which after D040 is the recommended one —
hiding its badge and CTA.

Drop the sticky positioning on the info card; let it scroll naturally
with the package headers. The row-label column below stays sticky on
its own, so scanning "Allowance for Coffin" etc. while scrolling right
still works. Removed the now-unused Z_HEADER_ROW constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:15:09 +10:00
65e34eef4b ComparisonTable: unverified providers show 'Unknown', not em-dash
Absence of data means different things for verified vs unverified:
- Verified providers itemise everything; a missing Optional/Extra is an
  explicit "Not Included" and a missing Essential is an (unlikely) gap.
- Unverified providers have scraped/estimated data; a missing cell just
  means "we don't know."

Route the two via `pkg.provider.verified` in lookupValue — unverified
packages now render missing cells as the existing "Unknown" + info-icon
treatment (already used by explicit `type: 'unknown'` cells). Verified
paths unchanged.

Drop the "Some providers have not provided an itemised pricing
breakdown" footer — the "Unknown" treatment is self-explanatory, and
the disclaimer was tied to the em-dash convention that no longer applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:11:48 +10:00
61db867e82 PackageDetail: keep CTAs side-by-side on mobile, size medium on xs
The Make Arrangement + Compare buttons stacked vertically on xs because
flexDirection was responsive. At size="large" (48px) the labels didn't
fit a ~360px mobile column side-by-side, which forced the stack. Drop
to size="medium" (40px) on xs and keep flexDirection fixed to row — the
two CTAs now sit beside each other on every viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:07:45 +10:00
ac598ea7b1 Comparison route: always include system-recommended package
The ComparisonPage's `recommendedPackage` prop was never wired in the
demo — users only saw their basket contents. Now always surface a
default recommended package (parsons:deluxe) as an extra column, deduped
against the basket so it never appears twice.

Basket mechanics are unchanged: the 3-package cap counts user selections
only, and the recommended is layered on top as an editorial suggestion.

The empty state only renders when there is genuinely nothing to show —
since the recommended is static, it's effectively defensive for a future
state where resolution could fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:03:04 +10:00
e5579a4d67 Vite demo config: load env files from repo root
Vite's default envDir is the `root` option, which here points into
`src/demo/apps/<slice>/` — no env files live there, so the Google Maps
API key from `.env.local` never made it into the production bundle and
ProviderMap silently fell back to its "no API key" empty state on
parsons.tensordesign.com.au. Set envDir to the repo root so `.env` and
`.env.local` are picked up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:58:33 +10:00
dcfbfc97ce PackagesStep: look up selected package in both primary + same-provider-more lists
Clicking a package in the "Other packages from [Provider]" section set
the selectedPackageId correctly but the detail panel stayed on the empty
state — `selectedPackage` was derived only from the primary `packages`
array, so secondary-list ids never resolved. Now falls back to the
secondary list when the primary miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:56:08 +10:00
9b6d541a6a Packages fixtures: drop 'unknown' treatment + add 10 verified packages
Dash fix: PackageDetail no longer renders em-dash placeholder rows for
Optionals. The 10 fixture entries that used `treatment: 'unknown'` are
removed; the Optional type narrows to IncludedTreatment only; the dead
'unknown' branch in optionalsForStep/optionalsForComparison is gone.
The rule going forward: an Optional/Extra exists on a package when the
package actually offers it. ComparisonTable already handles absence
correctly via buildMergedSections + lookupValue → 'Not Included'.

Package distribution expanded (max 5 per provider, randomised):
  parsons:  3 → 5  (+ traditional-burial, memorial-service)
  rankins:  2 → 3  (+ direct-cremation)
  killick:  2 → 3  (+ traditional-burial)
  mackay:   1 → 4  (+ premium, simple, memorial-service)
  mannings: 1 → 4  (+ premium, simple, direct-cremation)

Each new package follows the canonical-essentials rule: same 9 Essentials
line items, only prices/treatments vary. Optionals + Extras composed per
package. Mackay + Mannings comparison maps rewritten from single-package
to the indexed-array pattern used by parsons/rankins/killick, and their
bundle entries now slice(0,1)/slice(1) so an "Other packages from this
provider" section appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:52:39 +10:00
d2b648750f PackagesStep: surface verified providers via 2-col MiniCard grid
The unverified-tier "similar packages" section previously rendered a list
of NearbyPackageCards — one per package. Swap to MiniCard, showing the
provider itself: image, verified badge, location, rating, "From $X".
2-col on sm+, 1-col on xs, capped at 4. Heading dropped "nearby" to
"Similar packages from verified providers".

Data shape renamed NearbyVerifiedPackage → NearbyVerifiedProvider;
`verified` is implicit (the section is verified-only by definition).
Callback renamed onNearbyPackageClick → onNearbyProviderClick, routing
directly on provider id. Demo fixture now derives the list from the
main providers fixture (filtered to verified + imageUrl).

NearbyPackageCard is now orphaned — kept in place pending registry
cleanup in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:32:06 +10:00
4a0fcd0294 Stories: mobile CompareBar + refreshed InCart description
- CompareBar: add Mobile and MobileSingle stories (viewport: mobile1)
  so the xs-only collapse / auto-peek behaviour is discoverable in
  Storybook without setting up a live basket.
- PackageDetail InCart story description updated to match the final
  toggle pattern (was stale from the earlier inert-pill attempt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:30:38 +10:00
9281020d3a CompareBar: unify collapse animation + polish + z-index below drawer
- Single-Paper collapse: dropped the two-Slide scheme for a single
  right-anchored Paper on mobile. The middle content (status text +
  Compare button) animates to max-width:0 while the pill's right edge
  stays fixed, so the whole thing appears to retract to the corner as
  one unit rather than two stacked transitions.

- Collapse chevron: grey-filled circle (neutral-200 bg, neutral-300
  hover) that swaps between right-chevron (collapse) and left-chevron
  (expand) based on state. Always rendered — the IconButton stays in
  the layout so the icon swap happens in place.

- Collapsed badge: shows just the count ("1") instead of "1/3" so it
  reads as a circle at mini size. Min-width pinned to badge-height-md
  so any digit (1–3) renders circular. Expanded state keeps "N/3".

- z-index fix: CompareBar dropped from snackbar (1400) → drawer (1200);
  MapProviderDrawer raised from 3 → modal (1300). The drawer now
  visually covers the CompareBar when a pin or cluster is active on
  the mobile map view. CompareBar returns as soon as the drawer is
  dismissed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:18:26 +10:00
eef2ddc844 CompareBar: collapsible-on-mobile with auto-peek on add
Mobile users can now tap a right-chevron on the expanded bar to slide
it out; a mini peek-pill anchored bottom-right replaces it, showing
just the fraction badge (N/3) + a left-chevron to expand. Tap
anywhere on the mini-pill to bring the full bar back.

Packages-being-tallied feedback: when a new package is added while
the bar is collapsed, the full bar auto-peeks back in for 3 seconds,
then slides out again. The user sees the count update register
without having to tap to expand.

Two stacked Slide wrappers handle the direction-aware transitions:
- Full bar slides up from below (initial show + peek re-entry).
- Mini-pill slides in from the right (on user-triggered collapse).

Collapse state resets to expanded when the basket empties, so the
next fresh fill starts with the friendly default visible.

Desktop (md+) stays permanently expanded — the collapse chevron
doesn't render; there's plenty of space. Collapsing is a mobile-only
affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:03:57 +10:00
c1a3b30e91 CompareBar: bump bottom offset to clear HelpBar properly
Previous offset used theme.spacing(9) assuming 8px MUI default base —
but the FA theme uses a 4px base, so spacing(9) was only 36px and
the pill still sat 3px below the ~40px HelpBar. Bumped to spacing(16)
(64px) for a clean 25px gap above HelpBar on both mobile and desktop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:46:27 +10:00
4de8a916af CompareBar: raise above HelpBar, fix centering, responsive sizing on xs
- Raise bottom offset from theme.spacing(3) (24px) → theme.spacing(9)
  (72px) so the pill clears the sticky HelpBar with ~16px breathing.
- Centering: swap `left:50%; transform: translateX(-50%)` for
  `left:0; right:0; mx:auto; width:fit-content`. Slide (the wrapper)
  animates via transform, which was clobbering our centering transform
  and leaving the bar's left edge at the viewport centre instead of
  its centre (measured 171px off-centre pre-fix). Auto-margin
  centering doesn't fight Slide's animation.
- Mobile sizing: responsive step-down on xs —
  - Badge: large (32px) → medium (26px)
  - Typography: body1 (16px) → body2 (14px)
  - Button: medium (40px) → small (32px)
  - Container: gap 2→1.5, px 3→2, py 1.5→1
  md+ keeps the larger sizes from the earlier bump.

Rejected alternatives: slide/peek collapsed-state (adds interaction
cost and hides state behind a tap — bad for FA's grief-sensitive
audience); full-width bottom bar (loses the "floating reminder" pill
character).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:40:14 +10:00
d0462a87c8 Basket persists across in-app navigation
useBasketUrlSync was treating every searchParams change as a URL→Store
authority event. In practice this meant Back from PackagesStep to the
providers map landed on `/` (no `?compare=...`) and the hook called
setAll([]) — wiping the basket.

Changed the semantics so that when an in-app navigation drops the
`?compare=` param but the store still has items, we re-attach the
store's keys to the new URL rather than clearing the store. Shared
links still hydrate the store on initial mount, and the subscribe
that writes store→URL on basket changes is untouched.

With this, a user can:
- Add a package on Provider A's page.
- Back to the providers map (CompareBar stays, URL still shows
  `?compare=parsons:everyday`).
- Navigate into Provider B's page (URL carries the Parsons item forward).
- Add B's package (URL now `?compare=parsons:everyday,rankins:standard`).
- Hit Compare with 2/3 basket.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:23:36 +10:00
4404de5908 MapProviderDrawer: fix verified-icon alignment + header padding
- Cluster rows: mirror desktop ClusterPopup's alignment recipe — add
  justifyContent: 'center' on the verified-icon slot and an explicit
  lineHeight: 1.25 on the name Typography. Before: the slot's 1.25em
  computed off the inherited 16px while the body2 name computed off
  14px, producing a ~2.5px mismatch that put the tick slightly above
  the name's top line. Now the slot's vertical centre matches the
  name's line-box.
- Drawer header padding: px 1.5 → 2 so the "N providers in this area"
  heading aligns horizontally with the row content beneath it
  (rows use px: 2). Previously the heading sat slightly further left.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:11:37 +10:00
01751f5886 PackageDetail: keep "Compare" label in both states, move tick to right
Label stays "Compare" regardless of inCart. Only change between
default and added: a trailing check icon (endIcon) appears when the
package is in the basket. aria-pressed + aria-label still carry the
state for screen readers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:05:15 +10:00
7ecf309459 PackageDetail: toggle pattern for Compare button inCart state
Replaces the earlier inert selected-state treatment. Now:
- Button keeps its default soft/secondary chrome in both states — no
  separate brand-tinted visual.
- When `inCart=true`, a leading CheckRoundedIcon is added and the
  label swaps from "Compare" to "Added".
- Button remains clickable; `onCompare` is invoked in both states.
  Caller treats it as a toggle — add when absent, remove when present.
- aria-pressed reflects the state for SR users; aria-label spells
  "Add to comparison" / "Remove from comparison" explicitly.

Demo route swaps `basket.add()` for `basket.toggle()` on the handler
so a second click removes the package from the comparison basket.

Simpler visual (less space, one chrome to maintain) and a clearer
interaction — the user can undo directly from the detail panel
rather than hunting for CompareBar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:02:30 +10:00
13bd245872 PackageDetail: add inCart state for the Compare button
When a package is already in the comparison basket, the Compare button
swaps to a "In comparison" selected-state: soft brand-50 fill +
brand-300 border + brand-700 text + leading check icon. Technically
disabled (aria-disabled + no onClick) but sx-overrides the default
greyed Mui-disabled look so it reads as "selected/added," not
"unavailable."

Pattern: e-commerce "Added to cart" state. Removal happens via the
floating CompareBar (already owns basket mutation), not this button —
keeps the responsibility split clean.

API:
- PackageDetail: new `inCart?: boolean` prop.
- PackagesStep: forwarded as `isSelectedPackageInCart?: boolean`.
- Demo route (Packages.tsx): computes `basket.has(key)` for the
  current selection and passes it through.

Storybook: new PackageDetail story `InCart` alongside the existing
`Default` and `CompareLoading` states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:58:56 +10:00
75832ced24 CompareBar: bump fraction badge to large (32px)
Badge: medium (26px) → large (32px) — matches the visual weight of
the now-body1 status text and medium Compare button. Badge atom's
large variant uses the --fa-badge-*-lg tokens (height 32px, font-size
and icon-size stepped up together).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:50:30 +10:00
02b21a2cfe CompareBar: bump sizes, drop Compare icon
- Badge: small (22px) → medium (26px)
- Typography: body2 (14px) → body1 (16px)
- Compare Button: small (32px) → medium (40px)
- Container padding: px 2.5 → 3, py 1.25 → 1.5 for proportional breathing
- maxWidth: md 420 → 460 to accommodate larger Button + padding
- gap: 1.5 → 2 between elements
- Drop CompareArrowsIcon from the Compare button — the label alone
  reads clearly at the new size and removes visual noise

Positioning unchanged: the fixed bottom-centre pill defaults stay in
the component (it IS a floating compare pill — that's definitional),
with the caller's `sx` merged after so any page can override
(`sx={{ bottom: 96 }}`) when it needs to dodge another fixed element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:43:07 +10:00
a7db1974c3 Extract SortMenu molecule from ProvidersStep
Lifts the sort Button + anchored Menu pattern into a reusable molecule.
ProvidersStep previously had the same trigger-button + menu-items-with-
selected-state inline twice (mobile-map floating strip + desktop sticky
bar) with a minor variant split — "Sort by" compact label on mobile vs
"Sort: <label>" verbose label on desktop with a swap-vertical icon.

API: value (controlled string) + onChange + options array +
variant ('compact' | 'verbose') + sx (trigger chrome). Non-generic
string typing keeps the forwardRef clean; callers with typed unions
cast at the boundary (trivial, one line). Anchor state is fully
internal to the molecule.

Four Storybook stories (Compact, Verbose, Bare, TwoOptions) exercise
both variants, the bare-no-chrome default, and a non-provider options
set to demonstrate reuse.

Not tied to a product-specific sort domain — intended for VenueStep,
CoffinsStep, or any future page needing a sort menu. ProvidersStep's
SORT_OPTIONS stays in the page as the caller's domain data; the
molecule just renders whatever options it's given.

ProvidersStep cleanup: drops the Button, Menu, MenuItem, SwapVertIcon
imports and the sortAnchor state that only supported the inline
version.

Verified: compact label on mobile ("Sort by"), verbose label on
desktop ("Sort: Recommended" + swap icon), menu anchoring,
selected-state, aria-label all match pre-extraction behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:30:00 +10:00
2b39f43264 Promote HelpBar to a shared molecule
Lifts HelpBar out of WizardLayout's internal scope into
src/components/molecules/HelpBar/ so both WizardLayout and pages that
bypass WizardLayout's chrome (currently: ProvidersStep's mobile
map-first branch) render an identical sticky-footer.

Before: WizardLayout had an internal HelpBar with the right styling
(sticky, responsive px, phone format helper); ProvidersStep's
mobile-map branch hand-rewrote the footer inline and had drifted —
missing position: sticky, missing the md:4 responsive px, hard-coded
phone number bypassing the prop default. This consolidates both to
one source of truth.

Props: `phone?` (defaults to FA's support number, spaces preserved
in label, stripped in tel href) + `sx?` for caller chrome overrides.
Two Storybook stories (Default, CustomNumber).

Verified: footer text / height / sticky position identical between
mobile-list (WizardLayout) and mobile-map (direct HelpBar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:16:04 +10:00
4f433e2a8f Extract LocationSearchInput molecule from ProvidersStep
Lifts the committed-chip location search pattern out of ProvidersStep
(two identical inline call sites, ~60 lines each) into a reusable
molecule. Behaviour unchanged: draft-typing → commit on Enter or the
primary-filled search button → chip render → tap X to clear.

The molecule owns the non-obvious correctness CSS (endAdornment
absolute-anchoring + right-side padding lane) internally so future
callers don't have to rediscover it. Chrome (bgcolor, shadow, border,
radius) stays caller-controlled via the `sx` prop — selector keys for
internal vs caller rules are kept distinct (.MuiAutocomplete-inputRoot
vs .MuiOutlinedInput-root) to avoid sx-merge collisions.

API: value (committed, chip-rendered) + onChange (fires on commit OR
chip-delete) + optional onCommit (fires only on explicit commit, for
side effects beyond state).

ProvidersStep trims ~160 lines net, drops searchDraft/commitSearch/the
SearchIcon/LocationOnOutlinedIcon/IconButton imports that only existed
to power the two inline instances.

Four Storybook stories: Empty, WithCommittedValue, Unstyled,
WithOnCommit — enough to iterate the molecule without a live page.

Verified: delta=0px on the search button position (empty→draft→chip)
at both mobile and desktop widths — matches pre-extraction behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:11:08 +10:00
30ec88ceaf ProvidersStep: extract MapProviderDrawer + unify control chrome + fix search button drift
- New molecule MapProviderDrawer lifts the mobile-map bottom drawer out
  of ProvidersStep (~120 lines): Paper + close-X header + single-pin
  ProviderCard content / cluster-list content + slide-up animation.
  Props: `active: ProviderMapActiveState | null`, `onClose`,
  `onSelectProvider`, `onDrillIntoProvider`. Three Storybook states
  (SingleProvider, Cluster, ClusterPair, Closed) so the drawer can be
  iterated without a live map. ProvidersStep now consumes it as a
  single line wired to mapRef.clearActive + mapRef.drillIntoProvider.

- Shared visual tokens for the control cluster (Search, Filters, Sort by,
  List/Map toggle) factored into a CONTROL_CHROME constant and three
  typed sx objects (controlButtonSx, controlToggleSx, controlInputSx,
  filterTriggerSx) so all four controls share the same outline, radius,
  fill, and shadow across mobile list, mobile map, and desktop. Desktop
  map-panel floating toggle also re-threaded through controlToggleSx.

- Mobile list control order now matches mobile map: Sort by is grouped
  left next to Filters (not pushed right with a ml:auto wrapper), and
  the List/Map toggle is right-pinned via ml:auto on xs. Desktop keeps
  Sort pushed right (no toggle rendered on desktop in this slot).

- Fix: the magnifying-glass commit button was drifting 19–30px left as
  the input filled with chips / draft text. Root cause: overriding
  `InputProps.endAdornment` on Autocomplete bypasses MUI's
  `.MuiAutocomplete-endAdornment` absolute positioning, leaving our
  `.MuiInputAdornment-positionEnd` as `position: static` in flex flow.
  controlInputSx now re-absolutely-anchors the end adornment at the
  right edge and reserves `pr: 5` so input content can't slide under it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:39:52 +10:00
6434d11384 ProvidersStep mobile: unify list-view controls + PackagesStep drill-in fix
Controls
- Mobile list view's Filters/Sort/view-toggle now match the mobile map
  view's floating-chip treatment: white fill, neutral-300 border,
  shadow-sm. On mobile the sort label switches from "Sort: <value>" to
  a compact "Sort by"; desktop keeps its verbose label.
- List/Map toggle font bumped to 14px / 600 (button-small token), so
  it reads on the same line as Filters + Sort by both on mobile and
  on the desktop floating pill over the map panel.

PackagesStep drill-in
- Added a local hasDrilledIn flag so the mobile layout only swaps to
  the detail view after an explicit user tap on a package. Previously
  any pre-selection (the demo route seeds selectedPackageId to the
  first matching package for desktop auto-display) also forced mobile
  into the detail view, so users arriving from the map drawer saw a
  single package instead of the list. Back/forward from the detail
  resets the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:55:26 +10:00
22d14ef9bc ProvidersStep + MapPin: verified-icon alignment, filter tidy, drawer fixes
MapPin (both tiers)
- Verified providers now get an inline verified tick on the left of
  the name, matching the tick colour to the name so it reads as part
  of the label. Inline SVG (not @mui/icons-material) because MapPin
  is mounted via createRoot outside the ThemeProvider.
- Max label width bumped 180 → 210px to accommodate the icon without
  aggressively truncating long names.

Mobile cluster drawer rows
- Verified icon aligns with the name's top line (flex-start + 1.25em
  icon slot) — matches the desktop ClusterPopup layout.
- New right-aligned "From $X" price column, copper for verified.

Controls
- Mobile List/Map toggle: text labels (List, Map) instead of icons.
- Desktop List/Map toggle: resized to 32px height matching Filters +
  Sort buttons; bigger type, more padding.
- Search input corner radius now matches the button radius (8px)
  instead of the input radius (4px) so it reads as part of the chip
  set rather than a separate control.

Filter dialog (desktop + mobile)
- Remove the Location field — the sticky search bar is primary.
- Funeral-type chips bump small → medium.
- Reset filters button always renders; disabled when no filters are
  active (was previously hidden), so the affordance is discoverable.
- Provider-feature switches (Verified only, Online arrangements)
  align to the first text line so wrapped labels don't sink the
  switch visually below the second line.

Mobile drawer close
- drawerOpen now excludes the `exiting` phase, so tapping the X slides
  the drawer down immediately instead of lingering with a stale
  opacity fade. Visibility flips to hidden only after the slide ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:31:12 +10:00
7feb6582c4 ProvidersStep mobile: unify control outlines + shadows, square drawer bottom
- Search, Filters, Sort by, and the List/Map toggle now share a
  neutral-300 1px border and a shadow-sm drop, so the strip reads as a
  coherent set of floating chips over the map (not a mix of different
  button chromes)
- Drawer card now runs edge-to-edge inside the drawer with its own
  border + shadow stripped; the drawer Paper provides the top radius
  and the bottom is explicitly squared (no stray MUI default radius
  leaking through)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:52:54 +10:00
d91ad13af8 ProvidersStep mobile: white search fill + Sort by text trigger
- Mobile-map search input gets an explicit white fill so it reads
  cleanly against map tiles (desktop unchanged)
- Sort trigger on mobile-map is now a compact "Sort by" outlined
  Button instead of a lone icon — clearer affordance than the swap
  glyph, still tight on horizontal space

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:43:39 +10:00
705e85b37c ProvidersStep mobile: transparent control strip, icon-only sort, drawer header
- Drop the Paper container around the mobile-map floating controls —
  each control (Filters, Sort, view toggle) now carries its own white
  fill and reads over any map tile without a shared box
- Sort button becomes icon-only on mobile-map (the current sort is
  still communicated via the aria-label and the menu) — saves the
  row's horizontal budget
- Align all three controls to 32px height so Filters, Sort, and the
  List/Map toggle sit on a common baseline
- Move the drawer close X out of the image overlay area into a
  dedicated 40px drawer header bar; cluster header text ("N providers
  in this area") now lives in the same strip. No more overlap with the
  Verified badge on the card image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:57:48 +10:00
8c818fd5ac ProvidersStep: mobile map-first layout with bottom drawer
On xs + viewMode=map, render a map-first layout: full-bleed map,
floating card-shaped control strip at the top (search + Filters +
Sort + compact List/Map toggle), and a bottom drawer that slides up
when a pin or cluster is tapped. The desktop list-map layout is
unchanged.

On xs + viewMode=list, the List/Map toggle now appears in the sticky
control bar (icon-only) so users can reach the map from the list view.
On desktop the toggle stays on the map panel as before.

Drawer content:
- Single pin → the existing ProviderCard molecule, entire card
  clickable (navigates to packages)
- Cluster → a list of image-free rows (verified icon slot + name +
  location + rating), tap a row to pan+zoom into the provider
- Close X on the drawer clears the active state

To support externalising popups, ProviderMap gains two opt-in props
(`externalisePopups`, `onActiveChange`) and an imperative handle
(`clearActive`, `drillIntoProvider`). Desktop behaviour unchanged
when these aren't used. The forwardRef now exposes the handle rather
than the DOM element; no existing callsite passed a DOM ref.

The filter-dialog children are now defined once as a shared JSX
fragment used by both desktop and mobile FilterPanel instances.

Header + subhead are suppressed on the mobile map view (per concept
reference); they remain on desktop and mobile list for orientation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:39:08 +10:00
3bf5f72b4f ProvidersStep search: lock button, primary-circle, drop focus rings, grey chip
- Suppress Autocomplete's own popup/clear indicators (forcePopupIcon,
  clearIcon) so the search IconButton stays anchored in the same spot
  across empty, draft, and chip states
- Search button is a primary-filled circle at default strength in every
  state (no disabled dimming) — a clear affordance, handler already
  guards for empty drafts
- Drop the brand-gold focus ring on the search bar; keep the default
  neutral border on focus
- Drop the copper 2px focus outline on Filters and Sort (outline: none
  under :focus-visible)
- Committed location chip now uses the default neutral tonal fill
  instead of the promoted brand colour

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:53:04 +10:00
4d77d42876 ProvidersStep search: tighten icons, commit-to-chip, primary search button
Sticky search now uses Autocomplete (multiple+freeSolo capped to 1)
instead of a plain TextField:
- Pin icon tightened to the left edge and to the placeholder
- Committed location renders inside the input as an FA Chip with an
  X delete (clears the committed filter)
- Primary-coloured magnifying-glass IconButton on the right commits
  the draft; disabled while the draft is empty
- Typing no longer filters live — Enter or the search button promotes
  the draft to a chip, matching the chip mental model

The FilterPanel dialog's Location autocomplete already read from the
same searchQuery state, so it continues to display the committed chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:30:30 +10:00
952bdaea72 Clarify ProvidersStep sort button + bold results count
- Sort button now reads "Sort: <value>" so it's distinguishable from
  a filter; aria-label spells out the current sort. Price sort labels
  dropped their internal colons (avoids double-colon rendering).
- Results count bolds the number in primary text so it registers as
  the subject rather than incidental metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:12:28 +10:00
e78d88b2f3 Add Google Maps ProviderMap organism with clustering + popup flow
Introduces a full Google-Maps-backed provider map for the arrangement
wizard's ProvidersStep. Clicking a pin morphs it into a MapPopup at
the same coord; pins within 70px of each other collapse into a cluster
(ceiling at zoom 13) that opens a ClusterPopup list on click. Row
clicks pan + zoom the map to the provider and open their MapPopup.
Map-background click routes through an exit transition that fades the
popup out before reappearing the pin, via a matching fade-in keyframe
on the atom markers.

Key additions:
- @vis.gl/react-google-maps + @googlemaps/markerclusterer deps
- ClusterMarker atom (count badge; verified / unverified palettes)
- ClusterPopup molecule (image-free rows; verified icon aligned to
  name; right-aligned "From $X" column; verified-first sort)
- ProviderMap organism (APIProvider + Map + imperative AdvancedMarker
  layer via createRoot for clusterer compatibility)

Component changes:
- MapPin: promoted verified palette (brand-700); name now required;
  name-only and price-only variants dropped; active prop removed in
  favour of organism-level state; SVG nub with fill+stroke replaces
  the CSS border-triangle trick so the outline is continuous
- MapPopup: `exiting` prop drives close animation; click events stop
  propagation so the map's onClick can't clear state mid-interaction
- ProviderData type gains optional `coords`; demo fixtures populated
  with real NSW/QLD lat/lng for all 7 providers
- ProvidersStep demo route wires ProviderMap into the mapPanel slot

Memory:
- docs/memory/component-registry updated (ClusterMarker, ClusterPopup,
  ProviderMap added; MapPin + MapPopup refined; MapCard retired)
- docs/memory/session-log captures arc across 2026-04-21/22 and flags
  next-session work: ProvidersStep polish, mobile layout for list-map
  WizardLayout, and demo deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:29:37 +10:00
626666e6f0 Add demo:publish npm script for one-shot redeploy
Wraps the build + rsync into a single command for the routine
edit-and-ship loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:29:36 +10:00
cd7f99f59d Wire demo for production deploy + add server config
Fixes images 404'ing under /arrangement/ — Vite's publicDir copies assets
to the build root, but the base prefix is only applied to bundled assets
(JS/CSS), not to runtime URL strings. assetUrl() helper resolves paths
against import.meta.env.BASE_URL so '/images/foo.png' becomes
'/arrangement/images/foo.png' in production while staying '/images/foo.png'
in dev.

- src/demo/shared/assets.ts — assetUrl() helper
- providers.ts + DemoNav.tsx — wrap all public asset paths
- nginx/parsons-demos.conf — swag site-conf for parsons.tensordesign.com.au
  (asset cache regex above SPA fallback regex per nginx first-match rule)
- docs/reference/client-demo-deploy.md — server runbook (DNS, swag
  SUBDOMAINS, mount, htpasswd, deploy loop)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:22:40 +10:00
45d73759c1 Scaffold arrangement demo slice with CompareBar wiring
Add a self-contained demo build target for the Providers → Packages →
Comparison flow, deployable as a static SPA at /arrangement/.

- vite.demo.config.ts: per-slice build via --mode, base path flips for
  dev vs prod, output to dist-demo/<slice>/
- src/demo/: shared fixtures (7 providers across verified/tier3/tier2
  with real venue photography from brandassets) + Zustand basket store
  with ?compare= URL persistence
- Verified-provider packages now share the nine canonical Essentials
  line items per FA convention; only Optionals/Extras vary
- App-level CompareBar surfaces "Already added" / "Maximum 3" feedback
  via transient store error
- ProviderCard logo objectFit cover→contain so wide logos don't crop
- npm scripts demo:dev / demo:build, deps zustand + react-router-dom
2026-04-20 14:55:21 +10:00
e67872cb6a Unify PackagesStep across tiers + polish pass
Consolidate the three tier pages (PackagesStep, UnverifiedPackageT2,
UnverifiedPackageT3) into a single tier-aware PackagesStep with
providerTier: 'verified' | 'tier3' | 'tier2'. Copy, CTA label, price
disclaimer, and itemised-unavailable state all derive from tier via
an internal TIER_COPY map.

Extract NearbyPackageCard as a molecule (was duplicated inline in T2
and T3). Inherits Card atom's default elevated variant so shadow
matches the primary ServiceOption cards in the same column.

Add showAllFromProvider variant for the "See N more packages from
this provider" flow — flat list, no grouping, no secondary list,
preference filter dropped.

Polish pass on PackagesStep + PackageDetail:
- PackageDetail header band warm → white; added card drop-shadow.
- onCompare prop wire-through (button was built in but never exposed).
- Price disclaimer info-box: padding/gap/line-height tuned, icon
  alignment fixed (mt: '3px' matches codebase convention for 16px
  icons paired with body2 text).
- Left-column vertical rhythm: 48px gaps between provider card /
  subheading / list; 128px gap (Divider my: 8) between primary and
  secondary sections to separate groupings.
- Mobile drill-in navigation via useMediaQuery + display toggles.
  onSelectPackage widened to accept string | null; Back button
  swaps to "Back to packages" when a package is selected on mobile.
  Scrolls to top on drill-in.
- "See all" link copy: "See N more packages from this provider →"
  (overflow count, no provider name — sidesteps long-name wrapping).
- Verified provider image: placeholder URL → real local asset
  (hparsonsvenue.jpg, resized 2048×1366/591KB → 640×427/52KB).

Delete legacy PackageSelectPage story in PackageDetail.stories.tsx
(predated the real page components).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:45:57 +10:00
312a77aeb9 Polish ComparisonPage mobile cards + page layout
- ComparisonTabCard: width 210 → 235; recommended badge switched to
  filled brand + StarRoundedIcon matching the desktop
  ComparisonColumnCard treatment; removed glow + active glow shadow in
  favour of the standard shadow-sm; border colour brand-500 → brand-600;
  pt 2.4 → 3.5.
- ComparisonPackageCard: verified badge replaced with inline
  VerifiedOutlinedIcon to the left of the provider name (matches
  desktop pattern); warm tint confined to the header (Card body now
  explicitly white); 2px brand-600 border when recommended; header
  padding px 2.5 → 3, pt 2.5 → 3, pb 2 → 4; spacing pass across the
  provider identity / package info / sections groups — Divider my
  1.5 → 3, section mb 3 → 5, item py 1.5 → 2, heading→first-item 1.5
  → 2.5.
- ComparisonPage mobile: Divider between page header and tab rail;
  "Choose a package to view" h2 heading (user-centric copy), used as
  aria-labelledby for the tablist; dot indicator below the rail
  (8px grey, active 24×8 brand-600 pill) — aria-hidden and tabIndex=-1
  so the tab rail above remains the canonical accessible navigation.

Also leaves the Figma capture script in preview-head.html for future
page captures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:52:07 +10:00
f146bb0f81 Restructure ComparisonPage with bleed variant + sticky-left columns
Adopts a single-scroll-container layout for the desktop Comparison page
(motivated by a Figma Make exploration). The page header sits in a
max-width container matching the table's natural width, with flex
spacers either side of the table — when the viewport is wider than
the table, spacers centre it; when a 4th+ package pushes the table
wider than viewport, spacers collapse and the table extends rightward
from the page header's left edge.

- New WizardLayout variant `bleed` — viewport-locked, no inner Container,
  main is the single scroll host, back link routed into children,
  `data-wizard-scroll` marker for descendants.
- ComparisonTable: fixed 300px column widths exposed as
  COMPARISON_TABLE_COL_WIDTH; sticky-left on row-label column across
  every per-section mini-table; tiered hover (surface-subtle base /
  surface-warm recommended column); recommended column carries a
  resting 50%-opacity warm tint; "Not Included" copy replaces em-dash
  for unavailable cells in Optionals/Extras sections; CellIconText
  helper applies lineHeight: 1 so icon+text rows align optically.
- ComparisonColumnCard: uniform pt: 5 (40px); medium badge (26px) with
  star/verified icon; 2px brand-600 border for recommended; provider
  name wraps to 2 lines in a reserved 36px bottom-aligned slot so
  1-line names keep subsequent content on a consistent baseline; Remove
  link always rendered as the same Link element (visibility-hidden when
  not applicable) so CTA+footer align across all cards.
- Mackay test data extended to exercise 2-line wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:51:41 +10:00
356d22da4c Refine ComparisonColumnCard recommended state and CTA alignment
- Replace recommended banner with floating badge (star + primary fill)
  so CTA buttons align across recommended and non-recommended columns.
- Inline verified icon on recommended cards only (left of provider name,
  brand-600). Non-recommended verified providers keep the top badge alone.
- Override recommended card border to brand-600 for consistency with
  the rest of the primary system (token default is brand-500).
- Show dash in rating slot when provider has no rating — keeps heights
  consistent across the row.
- Invisible Remove placeholder on recommended card so primary CTAs align
  with cards that have a visible Remove link.
- Remove link dropped from body2 to caption (12px).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:36:22 +10:00
70 changed files with 6881 additions and 2356 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
dist-demo/
storybook-static/
tokens/export/
*.local
@@ -42,3 +43,6 @@ temp-db/
# Root-level screenshots
/*.png
# IDE-specific
*.code-workspace

View File

@@ -1,3 +1,4 @@
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

81
nginx/parsons-demos.conf Normal file
View File

@@ -0,0 +1,81 @@
# Parsons demo host — drop into swag's /config/nginx/site-confs/ directory.
#
# Serves static demo slices at parsons.tensordesign.com.au/<slice>/ behind
# basic auth. One server block, one cert (Let's Encrypt via swag), one
# htpasswd covering all slices.
#
# Document root layout (host filesystem):
# <host_path>/parsons-demos/
# index.html ← optional landing page listing slices
# arrangement/
# index.html
# assets/...
# <other-slices>/
#
# Bind-mount that directory into swag at /config/www/parsons-demos/ — the
# `root` directive below assumes that path. Adjust if you mount elsewhere.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name parsons.*;
# swag manages the cert chain via SUBDOMAINS — make sure `parsons` is in
# the SUBDOMAINS env var of the swag container so this resolves.
include /config/nginx/ssl.conf;
root /config/www/parsons-demos;
index index.html;
# One credential file covering every slice. Create with:
# docker exec -it swag htpasswd -c /config/nginx/.htpasswd-parsons client
auth_basic "Parsons demos";
auth_basic_user_file /config/nginx/.htpasswd-parsons;
# Optional: don't auth the root listing if you want it publicly visible.
# (Currently auth covers it too — change to `auth_basic off;` to expose.)
# Root path serves the optional landing index.html if present, else 404.
location = / {
try_files /index.html =404;
}
# Long cache for fingerprinted assets — Vite produces hashed filenames so
# this is safe. HTML is short-cache so updates land on next refresh.
# NOTE: asset + html regex locations must come BEFORE the slice fallback
# below, because nginx uses the first matching regex location.
location ~* \.(?:js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
# SPA fallback per slice. /<slice>/<react-route> resolves to that
# slice's index.html so React Router handles the rest. Static assets
# (.js/.css/.png/etc.) are handled by the regex blocks above.
location ~ ^/(?<slice>[^/]+)/ {
try_files $uri $uri/ /$slice/index.html;
}
# Hide hidden files (e.g. .htpasswd if it ever ends up in webroot)
location ~ /\. {
deny all;
}
}
# HTTP → HTTPS redirect — swag's default server already covers this for
# wildcard subdomains, but include explicitly here in case the default is
# customised.
server {
listen 80;
listen [::]:80;
server_name parsons.*;
return 301 https://$host$request_uri;
}

173
package-lock.json generated
View File

@@ -10,11 +10,15 @@
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0",
"@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react-dom": "^18.3.0",
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -1458,6 +1462,26 @@
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@googlemaps/js-api-loader": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
"license": "Apache-2.0",
"dependencies": {
"@types/google.maps": "^3.53.1"
}
},
"node_modules/@googlemaps/markerclusterer": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/supercluster": "^7.1.3",
"fast-equals": "^5.2.2",
"supercluster": "^8.0.1"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4106,6 +4130,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.64.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4169,6 +4205,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4478,6 +4523,21 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vis.gl/react-google-maps": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
"license": "MIT",
"dependencies": {
"@googlemaps/js-api-loader": "^2.0.2",
"@types/google.maps": "^3.54.10",
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -5387,6 +5447,19 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -6458,9 +6531,17 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -7850,6 +7931,12 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9530,6 +9617,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -9901,6 +10026,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10504,6 +10635,15 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -12099,6 +12239,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -19,16 +19,23 @@
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"chromatic": "chromatic --exit-zero-on-changes --build-script-name=build:storybook",
"demo:dev": "vite -c vite.demo.config.ts",
"demo:build": "vite build -c vite.demo.config.ts",
"demo:publish": "npm run demo:build -- --mode arrangement && ./scripts/deploy-demo.sh arrangement",
"prepare": "husky"
},
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0",
"@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react-dom": "^18.3.0",
"react-router-dom": "^7.14.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View File

@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterMarker } from './ClusterMarker';
const meta: Meta<typeof ClusterMarker> = {
title: 'Atoms/ClusterMarker',
component: ClusterMarker,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof ClusterMarker>;
/** Cluster containing at least one verified provider — promoted palette */
export const MixedOrVerified: Story = {
args: {
count: 5,
hasVerified: true,
},
};
/** Cluster of all-unverified providers — neutral palette */
export const AllUnverified: Story = {
args: {
count: 3,
hasVerified: false,
},
};
/** Small cluster — pair of providers */
export const Pair: Story = {
args: {
count: 2,
hasVerified: true,
},
};
/** Large cluster — double-digit count */
export const LargeCluster: Story = {
args: {
count: 27,
hasVerified: true,
},
};
/** Side-by-side comparison — verified vs unverified at various counts */
export const PaletteGrid: Story = {
render: () => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6,
p: 4,
}}
>
<ClusterMarker count={2} hasVerified />
<ClusterMarker count={5} hasVerified />
<ClusterMarker count={12} hasVerified />
<ClusterMarker count={99} hasVerified />
<ClusterMarker count={2} />
<ClusterMarker count={5} />
<ClusterMarker count={12} />
<ClusterMarker count={99} />
</Box>
),
};

View File

@@ -0,0 +1,161 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA ClusterMarker atom */
export interface ClusterMarkerProps {
/** Number of providers in this cluster */
count: number;
/** True if any provider in the cluster is verified — drives the promoted palette */
hasVerified?: boolean;
/** Click handler — opens the cluster popup */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const BADGE_SIZE = 36;
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
text: 'var(--fa-color-white)',
border: 'var(--fa-color-brand-700)',
nub: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
text: 'var(--fa-color-neutral-800)',
border: 'var(--fa-color-neutral-300)',
nub: 'var(--fa-color-neutral-100)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster map marker for the FA design system.
*
* Circular pill with a count, representing N provider pins grouped at the
* same screen location. Sibling to `MapPin` — same palette language (verified
* promoted, unverified neutral), same nub treatment, same shadow.
*
* `hasVerified` drives the palette: if *any* provider in the cluster is
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
* clusters use the neutral palette.
*
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
*
* Usage:
* ```tsx
* <ClusterMarker count={5} hasVerified onClick={...} />
* <ClusterMarker count={12} />
* ```
*/
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
({ count, hasVerified = false, onClick, sx }, ref) => {
const palette = hasVerified ? colours.verified : colours.unverified;
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
const label = `${count} providers in this area`;
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={label}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches MapPin and popups for a consistent
// entry timing across the map.
'@keyframes clusterMarkerIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'clusterMarkerIn 180ms ease-out',
'&:hover': { transform: 'scale(1.08)' },
'&:focus-visible': {
outline: 'none',
'& > .ClusterMarker-badge': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Circular badge */}
<Box
className="ClusterMarker-badge"
sx={{
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
color: palette.text,
fontFamily: 'var(--fa-font-family-body)',
fontSize: 14,
fontWeight: 700,
lineHeight: 1,
}}
>
{count}
</Box>
{/* Nub — same SVG pattern as MapPin for visual continuity */}
<svg
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},
);
ClusterMarker.displayName = 'ClusterMarker';
export default ClusterMarker;

View File

@@ -0,0 +1 @@
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';

View File

@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
export default meta;
type Story = StoryObj<typeof MapPin>;
/** Verified provider with name and price — warm brand label */
export const VerifiedWithPrice: Story = {
/** Verified provider — promoted brand palette (dark copper bg, white text) */
export const Verified: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
@@ -31,7 +31,7 @@ export const VerifiedWithPrice: Story = {
};
/** Unverified provider — neutral grey label */
export const UnverifiedWithPrice: Story = {
export const Unverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
@@ -39,66 +39,7 @@ export const UnverifiedWithPrice: Story = {
},
};
/** Active/selected state — inverted colours, slight scale-up */
export const Active: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
active: true,
},
};
/** Active unverified */
export const ActiveUnverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
active: true,
},
};
/** Name only — no price line */
export const NameOnly: Story = {
args: {
name: 'Lady Anne Funerals',
verified: true,
},
};
/** Name only, unverified */
export const NameOnlyUnverified: Story = {
args: {
name: 'Local Funeral Services',
},
};
/** Price-only pill — no name, verified */
export const PriceOnly: Story = {
args: {
price: 900,
verified: true,
},
};
/** Price-only pill — unverified */
export const PriceOnlyUnverified: Story = {
args: {
price: 1200,
},
};
/** Price-only pill — active */
export const PriceOnlyActive: Story = {
args: {
price: 900,
verified: true,
active: true,
},
};
/** Custom price label */
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Services',
@@ -141,7 +82,7 @@ export const MapSimulation: Story = {
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
<MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
@@ -152,12 +93,7 @@ export const MapSimulation: Story = {
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" onClick={() => {}} />
</Box>
{/* Name only verified */}
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
<MapPin name="Kenneallys" verified onClick={() => {}} />
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
</Box>
</>
),

View File

@@ -6,16 +6,14 @@ import type { SxProps, Theme } from '@mui/material/styles';
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** Provider or venue name — omit for a price-only pill */
name?: string;
/** Starting package price in dollars — shown as "From $X" */
/** Provider or venue name (required — shown as line 1) */
name: string;
/** Starting package price in dollars — shown as "From $X" on line 2 */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Whether this provider/venue is verified (brand colour vs neutral) */
/** Whether this provider/venue is verified (brand palette vs neutral palette) */
verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
@@ -27,34 +25,24 @@ export interface MapPinProps {
const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 180;
const MAX_WIDTH = 210;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-100)',
name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-600)',
activeBg: 'var(--fa-color-brand-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-100)',
activeNub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-300)',
activeBorder: 'var(--fa-color-brand-700)',
bg: 'var(--fa-color-brand-700)',
name: 'var(--fa-color-white)',
price: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
activeBg: 'var(--fa-color-neutral-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-neutral-200)',
nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)',
activeBorder: 'var(--fa-color-neutral-700)',
},
} as const;
@@ -68,26 +56,25 @@ const colours = {
* the exact map location.
*
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
* - **Line 2**: "From $X" (smaller, secondary colour)
*
* Visual distinction:
* - **Verified** providers: warm brand palette (gold bg, copper text)
* - **Verified** providers: warm brand palette (dark copper bg, white text)
* - **Unverified** providers: neutral grey palette
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
*
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
* Pure CSS — no canvas, no SVG dependency.
* Designed for use as custom HTML markers in Google Maps. Pure CSS — no
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
* organism level (ProviderMap swaps pin → popup on click).
*
* Usage:
* ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
* <MapPin name="H.Parsons" price={900} verified active />
* <MapPin name="Smith & Sons" price={1200} />
* <MapPin name="Botanical" priceLabel="POA" verified />
* ```
*/
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
({ name, price, priceLabel, verified = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
@@ -106,7 +93,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
@@ -116,7 +103,13 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
transform: active ? 'scale(1.08)' : 'scale(1)',
// Fade in on mount — matches the popup's exit timing so the pin
// reappears smoothly when a popup closes.
'@keyframes mapPinIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'mapPinIn 180ms ease-out',
'&:hover': {
transform: 'scale(1.08)',
},
@@ -142,53 +135,65 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg,
backgroundColor: palette.bg,
border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border,
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
transition:
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
}}
>
{/* Name */}
{name && (
{/* Name row — verified icon (left) + name */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
maxWidth: '100%',
}}
>
{verified && (
// Inline SVG of Material's Verified (outlined) icon. Kept as
// inline SVG because MapPin is mounted via createRoot outside
// the MUI ThemeProvider, so @mui/icons-material wouldn't pick
// up theme defaults.
<svg
aria-hidden
width="12"
height="12"
viewBox="0 0 24 24"
style={{ flexShrink: 0, fill: palette.name }}
>
<path d="M23 11.99l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.49 6.71 4.69 3.1 5.5l.34 3.7L1 11.99l2.44 2.79-.34 3.7 3.61.82 1.89 3.2L12 21.03l3.4 1.47 1.89-3.2 3.61-.82-.34-3.69L23 11.99zm-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
</svg>
)}
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily,
fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.3,
color: active ? palette.activeName : palette.name,
color: palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'color 150ms ease-in-out',
minWidth: 0,
}}
>
{name}
</Box>
)}
</Box>
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: !name ? 12 : 11,
fontWeight: !name ? 700 : 600,
fontFamily: (t: Theme) => t.typography.fontFamily,
fontSize: 11,
fontWeight: 600,
fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.2,
color: !name
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
color: palette.price,
whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}}
>
{priceText}
@@ -196,19 +201,33 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
)}
</Box>
{/* Nub — downward pointer */}
<Box
{/* Nub — downward pointer. Two SVG paths:
• fill is an extended pentagon that overhangs 3 units *into* the
pill's bg so sub-pixel scaling artifacts (hover transform) can't
expose the pill's bottom border through the seam;
• stroke is a separate open path on the two slanted sides only,
so the nub outline is continuous with the pill's border.
overflow: visible lets the fill render above the viewBox. */}
<svg
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE} solid transparent`,
borderRight: `${NUB_SIZE} solid transparent`,
borderTop: `${NUB_SIZE} solid`,
borderTopColor: active ? palette.activeNub : palette.nub,
mt: '-1px',
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
/>
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterPopup } from './ClusterPopup';
const meta: Meta<typeof ClusterPopup> = {
title: 'Molecules/ClusterPopup',
component: ClusterPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ClusterPopup>;
// Fixture data — mirrors the shape used in the demo
const mixedCluster = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
rating: 4.6,
startingPrice: 1800,
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
rating: 4.9,
startingPrice: 5200,
},
];
/** Mixed-tier cluster — verified providers sorted to top */
export const Mixed: Story = {
args: {
providers: mixedCluster,
onSelectProvider: (id) => {
alert(`Drill into ${id}`);
},
onClose: () => {
alert('Close cluster');
},
},
};
/** Small pair — two providers at the same location */
export const Pair: Story = {
args: {
providers: mixedCluster.slice(0, 2),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All verified — every provider in the cluster is a partner */
export const AllVerified: Story = {
args: {
providers: mixedCluster.filter((p) => p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All unverified — no partners in this cluster */
export const AllUnverified: Story = {
args: {
providers: mixedCluster.filter((p) => !p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** Tall cluster — scrolls when providers exceed visible area */
export const TallCluster: Story = {
args: {
providers: [
...mixedCluster,
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
],
onSelectProvider: () => {},
onClose: () => {},
},
};

View File

@@ -0,0 +1,360 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ButtonBase from '@mui/material/ButtonBase';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A provider summary used in the cluster list */
export interface ClusterPopupProvider {
/** Unique provider ID */
id: string;
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this is a verified/partner provider — drives sort order + colour accents */
verified?: boolean;
/** Average rating */
rating?: number;
/** Starting package price in dollars — shown as "From $X" on the right */
startingPrice?: number;
/** Custom price label (e.g. "POA") — overrides the formatted price */
priceLabel?: string;
}
/** Props for the FA ClusterPopup molecule */
export interface ClusterPopupProps {
/** Providers in this cluster */
providers: ClusterPopupProvider[];
/** Click handler — fires when a provider row is clicked */
onSelectProvider: (id: string) => void;
/** Close handler — fires when the close button is clicked */
onClose?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 320;
const MAX_CONTENT_HEIGHT = 360;
const NUB_SIZE = 8;
/** Fixed width reserved for the verified-icon slot so all row titles share
* the same x-origin regardless of whether the row is verified. */
const VERIFIED_SLOT_WIDTH = 18;
// ─── Row sub-component ──────────────────────────────────────────────────────
interface ProviderRowProps {
provider: ClusterPopupProvider;
onClick: () => void;
}
/**
* Single provider row inside the cluster list. Image-free layout:
* verified-icon slot (fixed width so titles align across rows) + name +
* location/rating meta. Full-width clickable surface. Clicking triggers
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
* location and opens their single-provider popup.
*/
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
const priceText =
provider.priceLabel ??
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
return (
<ButtonBase
onClick={(e) => {
// stopPropagation so the DOM click doesn't bubble to Map.onClick
// (which would clear state the same frame we're trying to drill in).
e.stopPropagation();
onClick();
}}
sx={{
width: '100%',
display: 'flex',
// flex-start so the verified-icon slot aligns with the name's top line,
// not the vertical centre of the row.
alignItems: 'flex-start',
gap: 1,
p: 1.25,
borderRadius: 1,
textAlign: 'left',
transition: 'background-color 120ms ease-in-out',
'&:hover': {
bgcolor: provider.verified
? 'var(--fa-color-brand-50)'
: 'var(--fa-color-surface-subtle)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: 2,
},
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits vertically on the name's line-box regardless of whether the
row has location/rating/price content below. */}
<Box
sx={{
width: VERIFIED_SLOT_WIDTH,
flexShrink: 0,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{provider.verified && (
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
aria-label="Verified provider"
/>
)}
</Box>
{/* Text column — name + location/rating meta */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
minWidth: 0,
lineHeight: 1.25,
}}
maxLines={1}
>
{provider.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'text.secondary',
flexWrap: 'wrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.location}
</Typography>
</Box>
{provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.rating}
</Typography>
</Box>
)}
</Box>
</Box>
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
Verified providers get the brand-600 copper price; unverified get
text.primary. "From" label uses caption/secondary for hierarchy. */}
{hasPrice && (
<Box
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
pt: '1px',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
From
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: 13,
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
lineHeight: 1.2,
}}
>
{priceText}
</Typography>
</Box>
)}
</ButtonBase>
);
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster popup card for the FA design system.
*
* Appears when a cluster marker is clicked. Shows the providers grouped at
* that map location as a scrollable stack of image-free rows — each row: a
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
* provider name (copper for verified, neutral for unverified) + location and
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
* ProviderMap flow, that pans and zooms the map to the provider's location
* before opening their single-provider popup — restoring spatial context
* that a list-only popup otherwise loses.
*
* Verified providers are sorted to the top of the list (business outcome:
* promote partner providers in any crowded cluster).
*
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
* 320px width, same `surface-subtle` header bar convention. Designed to
* render inside a Google Maps `AdvancedMarker`.
*
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
*
* Usage:
* ```tsx
* <ClusterPopup
* providers={[
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
* ]}
* onSelectProvider={(id) => drillIntoProvider(id)}
* onClose={() => closePopup()}
* />
* ```
*/
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
// Verified-first sort (stable within each tier)
const sorted = React.useMemo(
() =>
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
[providers],
);
return (
<Box
ref={ref}
// Swallow clicks on any empty space inside the popup (header, scroll
// gutter, etc.) so they don't bubble to Map.onClick and close us.
onClick={(e) => e.stopPropagation()}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes clusterPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
maxHeight: MAX_CONTENT_HEIGHT,
}}
>
{/* Header bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1.25,
bgcolor: 'var(--fa-color-surface-subtle)',
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
{providers.length} providers in this area
</Typography>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close cluster popup"
sx={{ mr: -0.5 }}
>
<CloseRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
{/* Provider list — scrollable */}
<Box
sx={{
overflowY: 'auto',
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
// Thin scrollbar styling
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.2)',
borderRadius: 3,
},
}}
>
{sorted.map((p) => (
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
))}
</Box>
</Paper>
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
<svg
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},
);
ClusterPopup.displayName = 'ClusterPopup';
export default ClusterPopup;

View File

@@ -0,0 +1 @@
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';

View File

@@ -85,6 +85,36 @@ export const Empty: Story = {
},
};
// --- Mobile ------------------------------------------------------------------
/** Mobile viewport — expanded by default, with a grey-filled right-chevron
* on the right of the pill. Tap the chevron to retract the pill to the
* right corner (the middle content animates to width:0, so the pill
* visually shrinks as one unit rather than swapping into a separate mini
* pill). Tap the left-chevron on the collapsed pill to expand. On add
* while collapsed, the full bar auto-peeks for 3s, then re-collapses. */
export const Mobile: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
/** Mobile — single package state. Same behaviour as `Mobile`, Compare
* CTA disabled ("Add another to compare"). */
export const MobileSingle: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */

View File

@@ -1,8 +1,12 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import type { SxProps, Theme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import IconButton from '@mui/material/IconButton';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import { useTheme, type SxProps, type Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
@@ -31,6 +35,14 @@ export interface CompareBarProps {
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
/** How long the bar stays expanded after a new package is added while
* collapsed. Long enough to read, short enough not to obstruct. */
const PEEK_DURATION_MS = 3000;
/** Middle-content expand/collapse duration (width + opacity). */
const COLLAPSE_MS = 300;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -39,16 +51,54 @@ export interface CompareBarProps {
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* Composes Badge + Button + Typography.
* **Mobile collapse** (xs only): users can tap a right-chevron to retract
* the pill to the right edge — the middle content (status text + Compare
* button) animates to width:0 while the pill stays anchored at the same
* right offset, so the whole thing appears to shrink into the corner as
* one unit rather than two separate elements. Tap again to expand. When
* a new package is added while collapsed, the bar auto-peeks for
* `PEEK_DURATION_MS` so the user sees the tally update, then re-collapses.
*
* Desktop (md+) stays expanded — there's plenty of space, and the
* collapse chevron is not rendered.
*
* Composes Badge + Button + Typography + IconButton.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
// Collapse state — mobile only. Starts expanded; when the basket empties
// we reset so the next fresh fill starts visible.
const [collapsed, setCollapsed] = React.useState(false);
const [peeking, setPeeking] = React.useState(false);
const lastCountRef = React.useRef(count);
React.useEffect(() => {
if (!visible) setCollapsed(false);
}, [visible]);
// Auto-peek when a package is added while collapsed.
React.useEffect(() => {
const prev = lastCountRef.current;
lastCountRef.current = count;
if (collapsed && count > prev) {
setPeeking(true);
const t = window.setTimeout(() => setPeeking(false), PEEK_DURATION_MS);
return () => window.clearTimeout(t);
}
}, [count, collapsed]);
/** Effective "is the middle content hidden?" — only on mobile, when the
* user has collapsed and we're not currently peeking. */
const mobileCollapsed = isMobile && collapsed && !peeking;
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
@@ -58,52 +108,123 @@ export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(theme: Theme) => ({
(t: Theme) => ({
position: 'fixed',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.snackbar,
// Clear the sticky HelpBar (~40px) + breathing room. FA theme
// uses a 4px spacing base, so spacing(16) = 64px.
bottom: t.spacing(16),
// z-index sits below the mobile map-view drawer (modal: 1300)
// but above app chrome (appBar: 1100). snackbar (1400) was too
// aggressive — the drawer visually covers this bar on mobile.
zIndex: t.zIndex.drawer,
// Mobile: right-anchored so when the middle collapses the pill
// appears to retract to the right corner. Desktop: centered.
...(isMobile
? { right: t.spacing(4), left: 'auto' }
: { left: 0, right: 0, mx: 'auto' }),
width: 'fit-content',
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
gap: { xs: 1.25, md: 2 },
px: { xs: 1.5, md: 3 },
py: { xs: 0.75, md: 1.5 },
maxWidth: { xs: 'calc(100vw - 32px)', md: 460 },
overflow: 'hidden',
transition: `padding ${COLLAPSE_MS}ms ease-out`,
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — 1/3, 2/3, 3/3 */}
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
{count}/3
</Badge>
{/* Status text */}
<Typography
variant="body2"
role={error ? 'alert' : undefined}
{/* Fraction badge — shows "N/3" when expanded, just "N" when
collapsed on mobile (reads as a circle at mini size). */}
<Badge
color="brand"
variant="soft"
size={isMobile ? 'medium' : 'large'}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
// When collapsed, force the badge toward a circle by
// equalising min-width and min-height at the medium-badge
// height (26px).
...(mobileCollapsed && {
minWidth: 'var(--fa-badge-height-md)',
justifyContent: 'center',
px: 0,
}),
}}
>
{error || statusText}
</Typography>
{mobileCollapsed ? count : `${count}/3`}
</Badge>
{/* Compare CTA */}
<Button
variant="contained"
size="small"
startIcon={<CompareArrowsIcon />}
onClick={onCompare}
disabled={!canCompare}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
{/* Middle content (status + Compare CTA) — animates to zero
max-width when collapsed, letting the pill shrink as one unit
with the right edge staying fixed. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1.25, md: 2 },
maxWidth: mobileCollapsed ? 0 : 600,
opacity: mobileCollapsed ? 0 : 1,
overflow: 'hidden',
transition: `max-width ${COLLAPSE_MS}ms ease-out, opacity ${Math.round(
COLLAPSE_MS * 0.6,
)}ms ease-out`,
}}
>
Compare
</Button>
<Typography
variant={isMobile ? 'body2' : 'body1'}
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
flexShrink: 0,
}}
>
{error || statusText}
</Typography>
<Button
variant="contained"
size={isMobile ? 'small' : 'medium'}
onClick={onCompare}
disabled={!canCompare}
tabIndex={mobileCollapsed ? -1 : 0}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Box>
{/* Mobile-only collapse/expand chevron — grey-filled circle that
swaps icon direction based on state. Rendered at all times so
the IconButton container stays in the layout and the icon swap
happens in place without mount/unmount. */}
{isMobile && (
<IconButton
aria-label={mobileCollapsed ? 'Show comparison basket' : 'Hide comparison basket'}
aria-expanded={!mobileCollapsed}
onClick={() => setCollapsed((c) => !c)}
size="small"
sx={{
flexShrink: 0,
width: 32,
height: 32,
borderRadius: '50%',
bgcolor: 'var(--fa-color-neutral-200)',
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-neutral-300)' },
}}
>
{mobileCollapsed ? (
<ChevronLeftRoundedIcon fontSize="small" />
) : (
<ChevronRightRoundedIcon fontSize="small" />
)}
</IconButton>
)}
</Paper>
</Slide>
);

View File

@@ -36,10 +36,11 @@ function formatPrice(amount: number): string {
/**
* Desktop column header card for the ComparisonTable.
*
* Shows provider info (verified badge, name, location, rating), package name,
* total price, CTA button, and optional Remove link. The verified badge floats
* above the card's top edge. Recommended packages get a copper banner and warm
* selected card state.
* Shows provider info (verified/recommended badge, name, location, rating),
* package name, total price, CTA button, and optional Remove link. The badge
* floats above the card's top edge — "Recommended" (primary fill) replaces
* "Verified" (soft) when the package is recommended. Recommended packages
* also get a warm selected card state with a brand-600 border.
*
* Used as the sticky header for each column in the desktop comparison grid.
* Mobile comparison uses ComparisonPackageCard instead.
@@ -61,23 +62,29 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Floating verified badge — overlaps card top edge */}
{pkg.provider.verified && (
{/* Floating badge — Recommended (primary fill) takes priority over Verified (soft) */}
{(pkg.isRecommended || pkg.provider.verified) && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
variant={pkg.isRecommended ? 'filled' : 'soft'}
size="medium"
icon={
pkg.isRecommended ? (
<StarRoundedIcon sx={{ fontSize: 16 }} />
) : (
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
)
}
sx={{
position: 'absolute',
top: -12,
top: -13,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
Verified
{pkg.isRecommended ? 'Recommended' : 'Verified'}
</Badge>
)}
@@ -85,24 +92,16 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
sx={{
overflow: 'hidden',
flex: 1,
display: 'flex',
flexDirection: 'column',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-600)',
}),
}}
>
{pkg.isRecommended && (
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
<Typography
variant="labelSm"
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Recommended
</Typography>
</Box>
)}
<Box
sx={{
display: 'flex',
@@ -110,40 +109,66 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
alignItems: 'center',
textAlign: 'center',
px: 2.5,
py: 2.5,
pt: pkg.provider.verified ? 3 : 2.5,
gap: 0.5,
pt: 5,
pb: 3,
gap: 1,
flex: 1,
}}
>
{/* Provider name (truncated with tooltip) */}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 24}
{/* Provider name — always reserves space for 2 lines (via minHeight),
content bottom-aligned so single-line names sit flush with the
next item below rather than floating high in the slot. */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
gap: 0.75,
maxWidth: '100%',
minHeight: 36, // 2 × (14px label × 1.286 line-height)
}}
>
<Typography
variant="label"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
{pkg.isRecommended && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
mb: '2px',
}}
aria-label="Verified provider"
/>
)}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 50}
>
{pkg.provider.name}
</Typography>
</Tooltip>
<Typography
variant="label"
sx={{
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
</Box>
{/* Location */}
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
{/* Rating */}
{pkg.provider.rating != null && (
{/* Rating (or dash placeholder to keep card heights consistent) */}
{pkg.provider.rating != null ? (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
@@ -154,20 +179,35 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
) : (
<Typography variant="body2" color="text.secondary" aria-label="No reviews yet">
</Typography>
)}
<Divider sx={{ width: '100%', my: 1 }} />
<Divider sx={{ width: '100%', my: 1.5 }} />
<Typography variant="h6" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
{/* Price subgroup — tighter internal spacing than the outer gap
so the label sits close to the amount it describes. */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
{/* Spacer pushes CTA to bottom across all cards */}
<Box sx={{ flex: 1 }} />
@@ -177,23 +217,33 @@ export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonC
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
onClick={() => onArrange(pkg.id)}
sx={{ mt: 1.5, px: 4 }}
sx={{ px: 4 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
{!pkg.isRecommended && onRemove && (
<Link
component="button"
variant="body2"
color="text.secondary"
underline="hover"
onClick={() => onRemove(pkg.id)}
sx={{ mt: 0.5 }}
>
Remove
</Link>
)}
{/* Always render the same Link element; hide when no Remove action
applies (recommended or no handler). Keeps the footer row
identical across all cards so CTAs align. */}
{(() => {
const canRemove = !pkg.isRecommended && !!onRemove;
return (
<Link
component="button"
variant="caption"
color="text.secondary"
underline="hover"
onClick={canRemove ? () => onRemove!(pkg.id) : undefined}
tabIndex={canRemove ? 0 : -1}
aria-hidden={!canRemove}
sx={{
...(!canRemove && { visibility: 'hidden', pointerEvents: 'none' }),
}}
>
Remove
</Link>
);
})()}
</Box>
</Card>
</Box>

View File

@@ -9,7 +9,6 @@ import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
@@ -125,12 +124,21 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
<Card
ref={ref}
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={[
{
overflow: 'hidden',
boxShadow: 'var(--fa-shadow-sm)',
// Body defaults to white; only the header carries the warm/subtle
// tint so the tint signals "provider" rather than washing the
// whole card.
bgcolor: 'background.paper',
// Match the desktop ComparisonColumnCard recommended treatment:
// explicit 2px brand-600 border (same as Card's selected state,
// but without the warm background wash that `selected` applies).
...(pkg.isRecommended && {
border: '2px solid var(--fa-color-brand-600)',
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
@@ -158,31 +166,38 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
bgcolor: pkg.isRecommended
? 'var(--fa-color-surface-warm)'
: 'var(--fa-color-surface-subtle)',
px: 2.5,
pt: 2.5,
pb: 2,
px: 3,
pt: 3,
pb: 4,
}}
>
{/* Verified badge */}
{pkg.provider.verified && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
sx={{ mb: 1 }}
>
Verified
</Badge>
)}
{/* Provider name */}
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{pkg.provider.name}
</Typography>
{/* Provider name with optional inline verified icon (matches desktop
ComparisonColumnCard treatment) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
mb: 1.25,
}}
>
{pkg.provider.verified && (
<VerifiedOutlinedIcon
sx={{
fontSize: 16,
color: 'var(--fa-color-brand-600)',
flexShrink: 0,
}}
aria-label="Verified provider"
/>
)}
<Typography variant="label" sx={{ fontWeight: 600 }}>
{pkg.provider.name}
</Typography>
</Box>
{/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
@@ -203,18 +218,22 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
)}
</Box>
<Divider sx={{ mb: 1.5 }} />
<Divider sx={{ my: 3 }} />
{/* Package name + price */}
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
{/* Package info group — name, label, price stacked with small internal gap */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
</Box>
</Box>
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
@@ -222,14 +241,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
size="medium"
fullWidth
onClick={() => onArrange(pkg.id)}
sx={{ mt: 2 }}
sx={{ mt: 3 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
</Box>
{/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, py: 2.5 }}>
<Box sx={{ px: 2.5, pt: 3.5, pb: 3 }}>
{pkg.itemizedAvailable === false ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
@@ -238,15 +257,14 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
</Box>
) : (
pkg.sections.map((section, sIdx) => (
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 5 : 0 }}>
{/* Section heading with left accent */}
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5,
mb: 1.5,
mt: sIdx > 0 ? 1 : 0,
mb: 2.5,
}}
>
<Typography variant="h6" component="h3">
@@ -262,7 +280,7 @@ export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, Comparison
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 1.5,
py: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import Box from '@mui/material/Box';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
@@ -58,12 +59,15 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Recommended badge in normal flow — overlaps card via negative mb */}
{/* Recommended badge in normal flow — overlaps card via negative mb.
Matches the desktop ComparisonColumnCard styling (filled brand +
star icon) for consistency between surfaces. */}
{pkg.isRecommended ? (
<Badge
color="brand"
variant="soft"
variant="filled"
size="small"
icon={<StarRoundedIcon sx={{ fontSize: 14 }} />}
sx={{
mb: '-10px',
zIndex: 1,
@@ -89,21 +93,18 @@ export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabC
onClick={onClick}
interactive
sx={{
width: 210,
width: 235,
cursor: 'pointer',
boxShadow: 'var(--fa-shadow-sm)',
...(pkg.isRecommended && {
borderColor: 'var(--fa-color-brand-500)',
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
borderColor: 'var(--fa-color-brand-600)',
}),
...(isActive && {
boxShadow: pkg.isRecommended
? '0 0 14px rgba(186, 131, 78, 0.4)'
: 'var(--fa-shadow-md)',
boxShadow: 'var(--fa-shadow-md)',
}),
}}
>
<Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
<Box sx={{ px: 2, pt: 3.5, pb: 2 }}>
<Typography
variant="labelSm"
sx={{

View File

@@ -77,8 +77,14 @@ export const FilterPanel = React.forwardRef<HTMLDivElement, FilterPanelProps>(
title={label}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{onClear && activeCount > 0 ? (
<Button variant="text" size="small" color="secondary" onClick={() => onClear()}>
{onClear ? (
<Button
variant="text"
size="small"
color="secondary"
onClick={() => onClear()}
disabled={activeCount === 0}
>
Reset filters
</Button>
) : (

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { HelpBar } from './HelpBar';
const meta: Meta<typeof HelpBar> = {
title: 'Molecules/HelpBar',
component: HelpBar,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
decorators: [
(Story) => (
// Fake page content so the sticky footer has something to sit under.
<Box sx={{ minHeight: 400, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, p: 4, bgcolor: 'background.default' }}>
Page content scrolls above the help bar.
</Box>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof HelpBar>;
/** Default — uses FA's standard support number. */
export const Default: Story = {};
/** Custom number — spaces preserved in the label, stripped in the tel link. */
export const CustomNumber: Story = {
args: { phone: '1300 000 000' },
};

View File

@@ -0,0 +1,64 @@
import React from 'react';
import Box from '@mui/material/Box';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA HelpBar molecule */
export interface HelpBarProps {
/** Phone number shown in the bar. Spaces preserved in the label,
* stripped in the `tel:` href. Defaults to FA's support number. */
phone?: string;
/** MUI sx prop — merged onto the default footer chrome. */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Sticky help footer used at the bottom of every wizard page. Shows a
* phone-icon prefix + "Need help? Call us on" + the support number as a
* tel-link. White fill, top border, sticky to the viewport bottom.
*
* Used by `WizardLayout` (for all variants that don't set `hideHelpBar`)
* and by pages that bypass WizardLayout's chrome (e.g. the mobile-map-first
* layout on `ProvidersStep`). Promoted from a WizardLayout-internal
* component so both sources render an identical footer — preventing drift
* if the phone number or styling ever changes.
*/
export const HelpBar = React.forwardRef<HTMLDivElement, HelpBarProps>(
({ phone = '1800 987 888', sx }, ref) => (
<Box
ref={ref}
component="footer"
sx={[
{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
),
);
HelpBar.displayName = 'HelpBar';
export default HelpBar;

View File

@@ -0,0 +1 @@
export { HelpBar, type HelpBarProps } from './HelpBar';

View File

@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import Box from '@mui/material/Box';
import { LocationSearchInput } from './LocationSearchInput';
const meta: Meta<typeof LocationSearchInput> = {
title: 'Molecules/LocationSearchInput',
component: LocationSearchInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ width: 360, p: 2, bgcolor: 'background.default' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof LocationSearchInput>;
// Caller-provided chrome mirroring the ProvidersStep chip strip — useful
// for visualising the molecule in its real context. Users of the molecule
// on other surfaces would pass their own (or none).
const providerChromeSx = {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
borderRadius: 'var(--fa-button-border-radius-default)',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
} as const;
// ─── Stories ────────────────────────────────────────────────────────────────
/** Empty state — no committed value, no draft. The primary magnifying-glass
* stays anchored to the right edge. */
export const Empty: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Committed-chip state — the value renders as a chip with an X to clear. */
export const WithCommittedValue: Story = {
render: (args) => {
const [value, setValue] = useState('Wollongong, 2500');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
args: { sx: providerChromeSx },
};
/** Unstyled — no caller chrome. Shows the raw molecule output (just the
* correctness CSS kicks in; the rest is MUI defaults). */
export const Unstyled: Story = {
render: (args) => {
const [value, setValue] = useState('');
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
},
};
/** With onCommit side-effect — logs when the user explicitly commits
* (separate from the always-fired onChange). */
export const WithOnCommit: Story = {
render: (args) => {
const [value, setValue] = useState('');
return (
<LocationSearchInput
{...args}
value={value}
onChange={setValue}
onCommit={(v) => {
console.log('committed:', v);
}}
/>
);
},
args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' },
};

View File

@@ -0,0 +1,199 @@
import React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import SearchIcon from '@mui/icons-material/Search';
import type { SxProps, Theme } from '@mui/material/styles';
import { Chip } from '../../atoms/Chip';
import { IconButton } from '../../atoms/IconButton';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA LocationSearchInput molecule */
export interface LocationSearchInputProps {
/** Committed location value. When non-empty, rendered as a chip inside
* the input; when empty, placeholder shows and the input accepts typing. */
value: string;
/** Fires whenever the committed value changes — on explicit commit (Enter
* or search button) with the new value, or on chip delete with ''. */
onChange: (value: string) => void;
/** Optional extra callback fired *only* on explicit commit (not on chip
* delete). Useful for triggering search side-effects beyond the value
* update (analytics, external fetch, etc.). */
onCommit?: (value: string) => void;
/** Placeholder text shown when no value is committed and no draft typed. */
placeholder?: string;
/** Accessible label for the input. */
'aria-label'?: string;
/** MUI sx prop — merged after the molecule's internal correctness CSS.
* Use this to style the outlined input's chrome (bgcolor, shadow, border,
* radius). Internal CSS targets `.MuiAutocomplete-inputRoot` whereas most
* chrome sx uses `.MuiOutlinedInput-root`, so collisions are avoided. */
sx?: SxProps<Theme>;
}
// ─── Internal correctness CSS ───────────────────────────────────────────────
/**
* Absolute-anchors the commit button (end adornment) to the right edge of
* the input — stock MUI Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts our button inside a
* `.MuiInputAdornment-positionEnd` that defaults to `position: static` and
* would slide left as chips / draft text fill the input.
*
* `pr: 5` on the input root reserves the right-edge lane so input content
* can't run under the button. Selectors use `.MuiAutocomplete-inputRoot`
* (not `.MuiOutlinedInput-root`) so caller sx for chrome can sit alongside
* these rules without colliding on the same key.
*/
const INTERNAL_SX = {
'& .MuiAutocomplete-inputRoot': {
position: 'relative',
pr: 5,
},
'& .MuiAutocomplete-inputRoot .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Location search input with committed-chip semantics.
*
* - **Typing produces a draft** (local state, not propagated).
* - **Pressing Enter or the primary-filled magnifying-glass button commits**
* the draft: fires `onChange(draft)` and `onCommit?.(draft)`, clears the
* draft, renders the committed value as a chip inside the input.
* - **Tapping the chip's X** clears the committed value (`onChange('')`).
*
* Capped to one chip at a time — if the user commits a new value while a
* chip exists, the new value replaces it. This matches the product intent
* (one active location per search) and keeps the UX obvious.
*
* The molecule owns the endAdornment absolute-anchoring + right-side
* padding so the commit button never drifts as chips / draft fill the input.
* Chrome (bgcolor, shadow, border, radius) is caller-controlled via `sx`.
*
* Originally extracted from ProvidersStep (D046) where the same pattern
* lived inline in both the mobile-map floating strip and the desktop/mobile
* sticky search bar.
*/
export const LocationSearchInput = React.forwardRef<HTMLDivElement, LocationSearchInputProps>(
(
{
value,
onChange,
onCommit,
placeholder = 'Search a town or suburb...',
'aria-label': ariaLabel = 'Search location',
sx,
},
ref,
) => {
const [draft, setDraft] = React.useState('');
const commit = (next: string) => {
const trimmed = next.trim();
if (!trimmed) return;
onChange(trimmed);
onCommit?.(trimmed);
setDraft('');
};
return (
<Autocomplete
ref={ref}
multiple
freeSolo
options={[]}
forcePopupIcon={false}
clearIcon={null}
value={value.trim() ? [value.trim()] : []}
inputValue={draft}
onInputChange={(_, newDraft, reason) => {
// Autocomplete fires a 'reset' input-change after a commit that
// would echo the committed value back into our draft — ignore it.
if (reason === 'reset') return;
setDraft(newDraft);
}}
onChange={(_, newValue) => {
if (newValue.length === 0) {
// Chip deleted
onChange('');
return;
}
// Cap at 1: take the most-recent entry as the new committed value.
const last = newValue[newValue.length - 1];
if (typeof last === 'string') commit(last);
}}
renderTags={(val, getTagProps) =>
val.map((option, index) => {
const { key, ...chipProps } = getTagProps({ index });
return (
<Chip
key={key}
label={option}
size="small"
aria-label={`Current location: ${option}. Press delete to clear.`}
{...chipProps}
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={value.trim() ? '' : placeholder}
size="small"
inputProps={{
...params.inputProps,
'aria-label': ariaLabel,
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="Search"
onClick={() => commit(draft)}
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&:focus-visible': { outline: 'none' },
}}
>
<SearchIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
),
}}
/>
)}
sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]}
/>
);
},
);
LocationSearchInput.displayName = 'LocationSearchInput';
export default LocationSearchInput;

View File

@@ -0,0 +1 @@
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';

View File

@@ -132,7 +132,7 @@ export const WithPin: Story = {
verified
onClick={() => {}}
/>
<MapPin name="H.Parsons" price={900} verified active />
<MapPin name="H.Parsons" price={900} verified />
</>
),
};

View File

@@ -31,6 +31,9 @@ export interface MapPopupProps {
verified?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
@@ -85,6 +88,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
capacity,
verified = false,
onClick,
exiting = false,
sx,
},
ref,
@@ -103,12 +107,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
}
}, [name]);
// Swallow clicks on the popup so they don't bubble to an enclosing
// Map.onClick (which would close the popup mid-click). Always applied,
// even when onClick is unset, because callers consistently render this
// molecule inside a map context where ambient clicks should not escape.
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
return (
<Box
ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onClick={handleClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
@@ -127,12 +140,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transition: 'transform 150ms ease-in-out',
'&:hover': onClick
? {
transform: 'scale(1.02)',
}
: undefined,
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes mapPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
'&:hover':
onClick && !exiting
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
@@ -149,6 +171,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
position: 'relative',
}}
>
{/* ── Image ── */}
@@ -279,19 +302,20 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
</Box>
</Paper>
{/* Nub — downward pointer connecting to pin */}
<Box
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
for depth instead of a hard border, so no stroke needed) */}
<svg
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE}px solid transparent`,
borderRight: `${NUB_SIZE}px solid transparent`,
borderTop: `${NUB_SIZE}px solid`,
borderTopColor: 'background.paper',
mt: '-1px',
}}
/>
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},

View File

@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapProviderDrawer } from './MapProviderDrawer';
const meta: Meta<typeof MapProviderDrawer> = {
title: 'Molecules/MapProviderDrawer',
component: MapProviderDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
decorators: [
// Simulate the mobile map-view container: fixed-size, relatively-positioned,
// with a faux map background behind the drawer.
(Story) => (
<Box
sx={{
position: 'relative',
width: 390,
height: 700,
mx: 'auto',
overflow: 'hidden',
// Very rough map-tile fill so the drawer has contrast behind it.
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
}}
>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MapProviderDrawer>;
// ─── Fixtures ───────────────────────────────────────────────────────────────
const parsons = {
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
imageUrl: '/images/funeral-homes/parsons-chapel.jpg',
logoUrl: '/images/providers/parsons-logo.png',
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
};
const clusterProviders = [
parsons,
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
rating: 4.9,
startingPrice: 3100,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
];
const log =
(label: string) =>
(arg?: string): void => {
console.log(label, arg ?? '');
};
// ─── Stories ────────────────────────────────────────────────────────────────
/** Single-provider drawer — the whole ProviderCard is clickable and fires
* `onSelectProvider` (in production, this navigates to the packages page). */
export const SingleProvider: Story = {
args: {
active: {
provider: parsons,
cluster: null,
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Cluster drawer — verified-first list of rows. Tapping a row fires
* `onDrillIntoProvider`; in production this pans + zooms the map and
* swaps the drawer's `active` to a single-provider state. */
export const Cluster: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders,
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Closed state — the drawer is in the DOM but translated off-screen. */
export const Closed: Story = {
args: {
active: null,
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Small cluster of two — verified pair. */
export const ClusterPair: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders.slice(0, 2),
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};

View File

@@ -0,0 +1,259 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ButtonBase from '@mui/material/ButtonBase';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { IconButton } from '../../atoms/IconButton';
import { Typography } from '../../atoms/Typography';
import { ProviderCard } from '../ProviderCard';
import type { ProviderData } from '../../pages/ProvidersStep';
import type { ProviderMapActiveState } from '../../organisms/ProviderMap';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapProviderDrawer molecule */
export interface MapProviderDrawerProps {
/** Current active state from `ProviderMap` (wire via `onActiveChange`).
* `null` = no active pin/cluster; drawer is hidden. */
active: ProviderMapActiveState | null;
/** Fires when the close X is tapped. Typically wired to the map's
* imperative `clearActive()`. */
onClose: () => void;
/** Fires when the single-provider card is tapped (entire card clickable).
* Typically navigates to that provider's packages. */
onSelectProvider: (id: string) => void;
/** Fires when a cluster row is tapped. Typically wired to the map's
* imperative `drillIntoProvider()` which pans + zooms + swaps the
* drawer's content to a single-provider card. */
onDrillIntoProvider: (id: string) => void;
/** MUI sx prop for the root Paper — merged onto the default positioning. */
sx?: SxProps<Theme>;
}
// ─── Cluster row ────────────────────────────────────────────────────────────
const ClusterRow: React.FC<{
provider: ProviderData;
onClick: () => void;
}> = ({ provider: p, onClick }) => (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
px: 2,
py: 1.25,
gap: 1,
// Start-align so the verified icon sits on the name's baseline —
// matches the desktop ClusterPopup row treatment.
alignItems: 'flex-start',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits on the name's line-box regardless of location/rating meta
below. Mirrors desktop ClusterPopup's treatment (D043 refinement). */}
<Box
sx={{
width: 18,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
lineHeight: 1.25,
mb: 0.25,
}}
>
{p.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<Typography variant="caption">{p.location}</Typography>
{p.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
</Box>
)}
</Box>
</Box>
{p.startingPrice != null && (
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
From
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
>
${p.startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</ButtonBase>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Bottom drawer that surfaces `ProviderMap`'s popup content outside the
* map itself. Used by the mobile map-first layout (see D045): the map
* runs full-bleed, and when a pin or cluster is tapped the drawer slides
* up from the bottom with the appropriate content.
*
* **Two content states, driven by `active`:**
* - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card
* clickable (fires `onSelectProvider`)
* - `active.cluster` → renders a verified-first list of rows (verified icon
* slot + name + location + rating + "From $X"); tapping a row fires
* `onDrillIntoProvider` which is wired to the map's imperative
* `drillIntoProvider()` (pans + zooms, then swaps `active` to that
* provider — the drawer content flips to the single-provider card).
*
* **Animation:** slides up via `transform: translateY()` + 220ms transition.
* When `active.exiting` is true, the drawer slides down immediately (the
* map organism is in the middle of its 180ms exit fade on the hidden pin
* beneath). `visibility: hidden` kicks in only after the slide completes,
* so the drawer stays in the DOM for the exit animation.
*
* **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0`
* by default — the consumer MUST render this inside a relatively-positioned
* container (typically the map-view `<main>`). Override via `sx` if needed.
*
* Related: row layout mirrors `ClusterPopup` (the anchored on-map variant);
* future consolidation possible if both container contracts converge.
*/
export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDrawerProps>(
({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => {
const provider = active?.provider ?? null;
const cluster = active?.cluster ?? null;
const isOpen = !!(active && !active.exiting && (provider || cluster));
const isExiting = !!active?.exiting;
const ariaLabel = provider
? `${provider.name} details`
: cluster
? `${cluster.providers.length} providers in this area`
: undefined;
return (
<Paper
ref={ref}
elevation={0}
role="dialog"
aria-label={ariaLabel}
aria-hidden={!isOpen}
sx={[
(t) => ({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
// Sit above the floating CompareBar (which uses zIndex.drawer)
// so that when a pin or cluster is active the drawer visually
// covers the bar, not vice versa.
zIndex: t.zIndex.modal,
maxHeight: '60vh',
overflow: 'auto',
borderRadius: 0,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
boxShadow: 'var(--fa-shadow-lg)',
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms ease-out',
pointerEvents: isOpen ? 'auto' : 'none',
visibility: isOpen || isExiting ? 'visible' : 'hidden',
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header strip — holds the close X (and the cluster count when
applicable) so neither sits over the card image below.
Horizontal padding matches the cluster rows (px: 2) so the
heading aligns with the row content beneath. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 40,
px: 2,
py: 0.5,
gap: 1,
}}
>
{cluster && !provider && (
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
{cluster.providers.length} providers in this area
</Typography>
)}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{
ml: 'auto',
width: 32,
height: 32,
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
{/* Single-provider content — entire card clickable. Card runs
edge-to-edge with all corners squared; the drawer Paper provides
the top radius. */}
{provider && (
<ProviderCard
name={provider.name}
location={provider.location}
verified={provider.verified}
imageUrl={provider.imageUrl}
logoUrl={provider.logoUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
startingPrice={provider.startingPrice}
onClick={() => onSelectProvider(provider.id)}
aria-label={`${provider.name}, ${provider.location}. Tap to view packages.`}
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
/>
)}
{/* Cluster list content — tap a row to drill in */}
{cluster && !provider && (
<Box sx={{ pb: 1 }}>
{[...cluster.providers]
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
.map((p) => (
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
))}
</Box>
)}
</Paper>
);
},
);
MapProviderDrawer.displayName = 'MapProviderDrawer';
export default MapProviderDrawer;

View File

@@ -0,0 +1 @@
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';

View File

@@ -172,7 +172,10 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: LOGO_BORDER_RADIUS,
objectFit: 'cover',
// 'contain' so wide/tall logos scale proportionally inside
// the square slot rather than cropping. Background fills any
// letterboxed space so it still reads as a tile.
objectFit: 'contain',
backgroundColor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
border: '2px solid var(--fa-color-white)',

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import Box from '@mui/material/Box';
import { SortMenu } from './SortMenu';
const meta: Meta<typeof SortMenu> = {
title: 'Molecules/SortMenu',
component: SortMenu,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof SortMenu>;
const providerSortOptions = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price low to high' },
{ value: 'price_high', label: 'Price high to low' },
];
// Caller-provided chrome mirroring ProvidersStep's chip strip.
const controlChromeSx = {
height: 32,
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
borderRadius: 'var(--fa-button-border-radius-default)',
boxShadow: 'var(--fa-shadow-sm)',
textTransform: 'none',
'&:hover': {
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
},
'&:focus-visible': { outline: 'none' },
} as const;
// ─── Stories ────────────────────────────────────────────────────────────────
/** Compact variant — the trigger reads "Sort by" regardless of current
* value. Current value surfaces in the menu's selected state. Best for
* narrow layouts (mobile). */
export const Compact: Story = {
render: (args) => {
const [value, setValue] = useState('recommended');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'compact',
sx: controlChromeSx,
},
};
/** Verbose variant — trigger reads "Sort: <current label>" with a
* swap-vertical icon. Best for desktop where horizontal space is cheap. */
export const Verbose: Story = {
render: (args) => {
const [value, setValue] = useState('price_low');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'verbose',
sx: controlChromeSx,
},
};
/** No chrome — raw output. Useful for checking the molecule's default
* Button atom appearance before any caller sx. */
export const Bare: Story = {
render: (args) => {
const [value, setValue] = useState('recommended');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'compact',
},
};
/** Smaller option set — demonstrating that the component adapts to any
* options array, not just the provider-sort defaults. */
export const TwoOptions: Story = {
render: (args) => {
const [value, setValue] = useState('newest');
return <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
],
variant: 'verbose',
sx: controlChromeSx,
},
};

View File

@@ -0,0 +1,118 @@
import React from 'react';
import Box from '@mui/material/Box';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import SwapVertIcon from '@mui/icons-material/SwapVert';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A sort option shown in the menu */
export interface SortOption {
/** Machine-readable value (e.g. 'price_low'). Passed back via `onChange`. */
value: string;
/** Human-readable label (e.g. 'Price low to high'). Shown in the menu and,
* in the `verbose` variant, on the trigger button. */
label: string;
}
/** Props for the FA SortMenu molecule */
export interface SortMenuProps {
/** Current sort value (controlled). Must match one of the options' values. */
value: string;
/** Fires when the user picks a different sort option. */
onChange: (value: string) => void;
/** Sort options to surface in the menu, in display order. */
options: SortOption[];
/** Trigger label variant:
* - `compact` (default): button reads just "Sort by"; current value
* surfaces only in the menu's selected item and in the aria-label.
* Best for narrow surfaces (mobile, chip-strip floating controls).
* - `verbose`: button reads "Sort: <current label>" with a leading
* swap-vertical icon. Best for desktop where horizontal space is
* cheap and the current value is worth surfacing inline. */
variant?: 'compact' | 'verbose';
/** MUI sx prop — applied to the trigger Button. Callers pass chrome
* (bgcolor, border, shadow, radius, height) here. */
sx?: SxProps<Theme>;
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Dropdown sort control — a trigger Button + anchored Menu.
*
* Tap the button → menu opens anchored to the button's bottom-right; pick
* an option → menu closes and `onChange` fires with the new value. The
* currently-selected option is visually marked in the menu (MUI's
* `selected` state on MenuItem).
*
* **Accessibility:** trigger button has `aria-haspopup="listbox"` and an
* `aria-label` that spells out the current sort ("Sort by Recommended"),
* so screen-reader users get the state regardless of which label variant
* is rendered. Selected MenuItem has `aria-selected="true"` via MUI.
*
* Originally extracted from ProvidersStep (which had the same Button +
* Menu pattern inline in two places with a minor "Sort by" vs
* "Sort: <label>" difference). Intended for reuse on VenueStep,
* CoffinsStep, or anywhere a sort menu is needed.
*/
export const SortMenu = React.forwardRef<HTMLButtonElement, SortMenuProps>(
({ value, onChange, options, variant = 'compact', sx }, ref) => {
const [anchor, setAnchor] = React.useState<null | HTMLElement>(null);
const current = options.find((o) => o.value === value);
const ariaLabel = `Sort by ${current?.label ?? 'default'}`;
return (
<>
<Button
ref={ref}
variant="outlined"
color="secondary"
size="small"
startIcon={variant === 'verbose' ? <SwapVertIcon sx={{ fontSize: 16 }} /> : undefined}
onClick={(e) => setAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={ariaLabel}
sx={sx}
>
{variant === 'compact' ? (
'Sort by'
) : (
<>
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
Sort:
</Box>
{current?.label ?? ''}
</>
)}
</Button>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{options.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === value}
onClick={() => {
onChange(opt.value);
setAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
</>
);
},
);
SortMenu.displayName = 'SortMenu';
export default SortMenu;

View File

@@ -0,0 +1 @@
export { SortMenu, type SortMenuProps, type SortOption } from './SortMenu';

View File

@@ -346,7 +346,7 @@ export const MixedVerified: Story = {
// --- Missing Itemised Data ---------------------------------------------------
/** One provider has no itemised breakdown — cells show "" */
/** One provider has no itemised breakdown — unverified cells show "Unknown" */
export const MissingData: Story = {
args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay],

View File

@@ -63,7 +63,55 @@ function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function CellValue({ value }: { value: ComparisonCellValue }) {
/**
* Inline icon + label wrapper with optically aligned centres.
*
* body2's line-height adds vertical padding above/below the glyphs. Flex
* centring then aligns geometric centres, which puts the icon slightly
* above the text's visual centre. Setting `lineHeight: 1` on the row
* collapses the text line-box to the font size so geometric and visual
* centres match.
*/
function CellIconText({
icon,
iconPosition = 'leading',
color,
children,
}: {
icon: React.ReactNode;
iconPosition?: 'leading' | 'trailing';
color: string;
children: React.ReactNode;
}) {
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
lineHeight: 1,
}}
>
{iconPosition === 'leading' && icon}
<Typography variant="body2" sx={{ color, fontWeight: 500, lineHeight: 1 }} component="span">
{children}
</Typography>
{iconPosition === 'trailing' && icon}
</Box>
);
}
/** Sections where a missing item is better expressed as "Not Included"
* than a bare em-dash — these are opt-in items, so absence is meaningful. */
const OPTIONAL_SECTION_HEADINGS = new Set(['Optionals', 'Extras']);
function CellValue({
value,
sectionHeading,
}: {
value: ComparisonCellValue;
sectionHeading: string;
}) {
switch (value.type) {
case 'price':
return (
@@ -79,33 +127,31 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
);
case 'complimentary':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Complimentary
</Typography>
</Box>
<CellIconText
color="var(--fa-color-feedback-success)"
icon={
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
}
>
Complimentary
</CellIconText>
);
case 'included':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Included
</Typography>
</Box>
<CellIconText
color="var(--fa-color-feedback-success)"
icon={
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
}
>
Included
</CellIconText>
);
case 'poa':
return (
@@ -115,20 +161,30 @@ function CellValue({ value }: { value: ComparisonCellValue }) {
);
case 'unknown':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CellIconText
color="var(--fa-color-neutral-500)"
iconPosition="trailing"
icon={
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
}
>
Unknown
</CellIconText>
);
case 'unavailable':
if (OPTIONAL_SECTION_HEADINGS.has(sectionHeading)) {
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
>
Unknown
Not Included
</Typography>
<InfoOutlinedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
aria-hidden
/>
</Box>
);
case 'unavailable':
);
}
return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
@@ -170,11 +226,20 @@ function lookupValue(
sectionHeading: string,
itemName: string,
): ComparisonCellValue {
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
// For unverified providers, absence means "we don't know" — data is
// scraped/estimated. For verified providers, absence means the package
// explicitly doesn't include this item (→ "Not Included" in Optionals/
// Extras; em-dash in Essentials as a safety net — canonical-essentials
// rule says every verified package has all 9, so this path shouldn't
// fire in practice).
const missing: ComparisonCellValue = pkg.provider.verified
? { type: 'unavailable' }
: { type: 'unknown' };
if (pkg.itemizedAvailable === false) return missing;
const section = pkg.sections.find((s) => s.heading === sectionHeading);
if (!section) return { type: 'unavailable' };
if (!section) return missing;
const item = section.items.find((i) => i.name === itemName);
if (!item) return { type: 'unavailable' };
if (!item) return missing;
return item.value;
}
@@ -207,6 +272,18 @@ const tableSx = {
bgcolor: 'background.paper',
};
/**
* Fixed column width for both the row-label column and each package column.
* Natural table width = COMPARISON_TABLE_COL_WIDTH × (packages.length + 1).
* Exposed so ComparisonPage can size its width-matching page header container
* to align left edges with the table on horizontal overflow.
*/
export const COMPARISON_TABLE_COL_WIDTH = 300;
/** z-index scale for sticky layers inside the table. */
const Z_STICKY_LEFT = 20;
const Z_STICKY_LEFT_SECTION = 25; // section heading left cell above body cells
// ─── Component ──────────────────────────────────────────────────────────────
/**
@@ -219,10 +296,10 @@ const tableSx = {
*/
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => {
const colCount = packages.length + 1;
const mergedSections = buildMergedSections(packages);
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
const colCount = packages.length + 1;
const gridCols = `${COMPARISON_TABLE_COL_WIDTH}px repeat(${packages.length}, ${COMPARISON_TABLE_COL_WIDTH}px)`;
const recommendedColIdx = packages.findIndex((p) => p.isRecommended);
return (
<Box
@@ -232,32 +309,34 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
sx={[
{
display: { xs: 'none', md: 'block' },
overflowX: 'auto',
width: COMPARISON_TABLE_COL_WIDTH * colCount,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box sx={{ minWidth: minW }}>
{/* ── Package header cards ── */}
<Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: 2,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* Info card — stretches to match package card height, text at top */}
{/* ── Package header cards ── */}
<Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* Info card — scrolls with the package columns. Previously
sticky-left to mirror the row-label column, but that pinned
it over the leftmost (recommended) package on horizontal
scroll. The row labels below stay sticky on their own. */}
<Box sx={{ px: 2 }}>
<Card
role="columnheader"
variant="elevated"
padding="default"
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
alignSelf: 'stretch',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
@@ -276,71 +355,117 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
Review and compare features side-by-side to find the right fit.
</Typography>
</Card>
</Box>
{/* Package column header cards */}
{packages.map((pkg) => (
{packages.map((pkg) => (
<Box key={pkg.id} sx={{ px: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<ComparisonColumnCard
key={pkg.id}
pkg={pkg}
onArrange={onArrange}
onRemove={onRemove}
sx={{ flex: 1 }}
/>
))}
</Box>
</Box>
))}
</Box>
{/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
{/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
{/* Section heading row — left cell sticky so label stays visible on horizontal scroll */}
<Box
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
}}
>
<Box
sx={{
position: 'sticky',
left: 0,
zIndex: Z_STICKY_LEFT_SECTION,
gridColumn: '1 / 2',
}}
>
<SectionHeading>{section.heading}</SectionHeading>
</Box>
{/* Background continuation for the remaining columns so they
share the heading's surface-subtle wash. */}
<Box
aria-hidden
sx={{
gridColumn: `2 / ${colCount + 1}`,
bgcolor: 'var(--fa-color-surface-subtle)',
}}
/>
</Box>
{section.items.map((item) => (
{section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
// Tiered hover: base cells go to surface-subtle, recommended
// column cells inherit a warmer surface-warm tint on row hover.
'&:hover .comparison-cell': {
bgcolor: 'var(--fa-color-surface-subtle)',
},
'&:hover .comparison-cell--recommended': {
bgcolor: 'var(--fa-color-surface-warm)',
},
}}
>
{/* Row-label cell — sticky-left */}
<Box
key={item.name}
role="row"
role="cell"
className="comparison-cell comparison-cell--label"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
position: 'sticky',
left: 0,
zIndex: Z_STICKY_LEFT,
bgcolor: 'background.paper',
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}}
>
<Box
role="cell"
sx={{
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
{item.name}
</Typography>
{item.info && (
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
{'\u00A0'}
<Tooltip title={item.info} arrow placement="top">
<InfoOutlinedIcon
aria-label={`More information about ${item.name}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
<Typography variant="body2" color="text.secondary" component="span">
{item.name}
</Typography>
{item.info && (
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
{'\u00A0'}
<Tooltip title={item.info} arrow placement="top">
<InfoOutlinedIcon
aria-label={`More information about ${item.name}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
{packages.map((pkg) => (
{packages.map((pkg, idx) => {
const isRecommended = idx === recommendedColIdx;
return (
<Box
key={pkg.id}
role="cell"
className={
'comparison-cell' + (isRecommended ? ' comparison-cell--recommended' : '')
}
sx={{
display: 'flex',
alignItems: 'center',
@@ -351,23 +476,26 @@ export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableP
borderColor: 'divider',
borderLeft: '1px solid',
borderLeftColor: 'divider',
transition: 'background-color 0.15s ease',
// Resting tint for the recommended column so it reads
// as the default column even without hover.
...(isRecommended && {
bgcolor:
'color-mix(in srgb, var(--fa-color-surface-warm) 50%, transparent)',
}),
}}
>
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
<CellValue
value={lookupValue(pkg, section.heading, item.name)}
sectionHeading={section.heading}
/>
</Box>
))}
</Box>
))}
</Box>
))}
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
* Some providers have not provided an itemised pricing breakdown. Their items are
shown as "—" above.
</Typography>
)}
</Box>
);
})}
</Box>
))}
</Box>
))}
</Box>
);
},

View File

@@ -1,4 +1,4 @@
export { ComparisonTable, default } from './ComparisonTable';
export { ComparisonTable, COMPARISON_TABLE_COL_WIDTH, default } from './ComparisonTable';
export type {
ComparisonTableProps,
ComparisonPackage,

View File

@@ -1,17 +1,6 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { PackageDetail } from './PackageDetail';
import { ServiceOption } from '../../molecules/ServiceOption';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Navigation } from '../Navigation';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
const DEMO_IMAGE =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
const essentials = [
{
@@ -117,41 +106,6 @@ const extras = {
const termsText =
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
const packages = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 900,
description:
'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.',
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: 1200,
description: 'An enhanced package with premium coffin and additional floral arrangements.',
},
{
id: 'essential',
name: 'Essential Funeral Package',
price: 600,
description: 'A simple, dignified service covering all necessary arrangements.',
},
{
id: 'catholic',
name: 'Catholic Service',
price: 950,
description:
'A service tailored for Catholic traditions including prayers and church ceremony.',
},
];
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
const meta: Meta<typeof PackageDetail> = {
title: 'Organisms/PackageDetail',
component: PackageDetail,
@@ -205,6 +159,24 @@ export const CompareLoading: Story = {
},
};
/** "Added to comparison" state — package is already in the basket.
* The Compare button keeps its default soft/secondary chrome + "Compare"
* label, and gains a trailing check icon. Click is a toggle — the
* caller wires `onCompare` to add-or-remove based on the `inCart` prop
* it's passing in (e.g. via `basket.toggle(key)`). aria-pressed and the
* aria-label spell out the state for SR users. */
export const InCart: Story = {
args: {
name: 'Traditional Family Cremation Service',
price: 6966,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6966,
onArrange: () => alert('Make Arrangement'),
onCompare: () => {},
inCart: true,
},
};
// --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and optionals only — no extras */
@@ -222,132 +194,3 @@ export const WithoutExtras: Story = {
onCompare: () => alert('Compare'),
},
};
// --- Package Select Page Layout ----------------------------------------------
/** Full page layout — left: package list, right: detail panel */
export const PackageSelectPage: Story = {
decorators: [
(Story) => (
<Box sx={{ maxWidth: 'none', width: '100%' }}>
<Story />
</Box>
),
],
render: () => {
const [selectedPkg, setSelectedPkg] = useState('everyday');
const [activeFilter, setActiveFilter] = useState('Cremation');
const [comparing, setComparing] = useState(false);
const handleCompare = () => {
setComparing(true);
setTimeout(() => setComparing(false), 1500);
};
return (
<Box>
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'Provider Portal', href: '/provider-portal' },
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: { xs: 3, md: 4 },
maxWidth: 'lg',
mx: 'auto',
px: { xs: 2, md: 4 },
py: { xs: 2, md: 4 },
alignItems: 'start',
}}
>
{/* Left column */}
<Box>
<Button
variant="text"
color="secondary"
startIcon={<ArrowBackIcon />}
sx={{ mb: 2, ml: -1 }}
>
Back
</Button>
<Typography variant="h2" sx={{ mb: 3 }}>
Select a package
</Typography>
<ProviderCardCompact
name="H.Parsons"
location="Wentworth"
imageUrl={DEMO_IMAGE}
rating={4.5}
reviewCount={11}
sx={{ mb: 3 }}
/>
{/* Funeral type filter */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
{funeralTypes.map((type) => (
<Chip
key={type}
label={type}
variant={activeFilter === type ? 'filled' : 'outlined'}
selected={activeFilter === type}
onClick={() => setActiveFilter(type)}
size="small"
/>
))}
</Box>
<Typography variant="h4" sx={{ mb: 2 }}>
Packages
</Typography>
<Box
role="radiogroup"
aria-label="Available packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
price={pkg.price}
description={pkg.description}
selected={selectedPkg === pkg.id}
onClick={() => setSelectedPkg(pkg.id)}
maxDescriptionLines={2}
/>
))}
</Box>
</Box>
{/* Right column: package detail */}
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
<PackageDetail
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
sections={[
{ heading: 'Essentials', items: essentials },
{ heading: 'Optionals', items: optionals },
]}
total={6966}
extras={extras}
terms={termsText}
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
onCompare={handleCompare}
compareLoading={comparing}
/>
</Box>
</Box>
</Box>
);
},
};

View File

@@ -1,6 +1,9 @@
import React from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
@@ -53,6 +56,11 @@ export interface PackageDetailProps {
arrangeDisabled?: boolean;
/** Whether the compare button is in loading state */
compareLoading?: boolean;
/** Whether this package is already in the comparison basket. When true,
* the Compare button swaps its label to "Added" and adds a leading check
* icon. The button remains clickable — the caller is expected to treat
* `onCompare` as a toggle (add when not in cart, remove when in cart). */
inCart?: boolean;
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
arrangeLabel?: string;
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
@@ -124,6 +132,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
terms,
onArrange,
onCompare,
inCart = false,
arrangeDisabled = false,
compareLoading = false,
arrangeLabel = 'Make Arrangement',
@@ -133,6 +142,11 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
},
ref,
) => {
// CTA buttons stay side-by-side on all viewports; size down on xs so
// "Make Arrangement" + "Compare" fit a ~360px mobile column without wrap.
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const ctaSize = isMobile ? 'medium' : 'large';
return (
<Box
ref={ref}
@@ -141,6 +155,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
boxShadow: 'var(--fa-card-shadow-default)',
overflow: 'hidden',
},
...(Array.isArray(sx) ? sx : [sx]),
@@ -149,7 +164,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{/* Header band — warm bg to separate from content */}
<Box
sx={{
bgcolor: 'var(--fa-color-surface-warm)',
bgcolor: 'background.paper',
px: { xs: 2, sm: 3 },
pt: 3,
pb: 2.5,
@@ -178,10 +193,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
gap: 1.25,
mt: 1.5,
px: 1.5,
py: 1,
px: 2,
py: 1.5,
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
borderRadius: 'var(--fa-border-radius-sm, 6px)',
border: '1px solid',
@@ -189,22 +204,20 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
}}
>
<InfoOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
{priceDisclaimer}
</Typography>
</Box>
)}
{/* CTA buttons */}
<Box
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
>
{/* CTA buttons — always side-by-side */}
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1.5, mt: 2.5 }}>
<Button
variant="contained"
size="large"
size={ctaSize}
fullWidth
disabled={arrangeDisabled}
onClick={onArrange}
@@ -212,12 +225,19 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{arrangeLabel}
</Button>
{onCompare && (
// Same soft/secondary chrome + "Compare" label in both states;
// when the package is in the basket a trailing check icon
// appears. Click is a toggle — caller decides to add or remove
// based on the `inCart` it's passing in.
<Button
variant="soft"
color="secondary"
size="large"
size={ctaSize}
loading={compareLoading}
endIcon={inCart ? <CheckRoundedIcon /> : undefined}
onClick={onCompare}
aria-pressed={inCart}
aria-label={inCart ? 'Remove from comparison' : 'Add to comparison'}
sx={{ flexShrink: 0 }}
>
Compare

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ProviderMap } from './ProviderMap';
import { providers as demoProviders } from '../../../demo/shared/fixtures/providers';
import type { ProviderData } from '../../pages/ProvidersStep';
const meta: Meta<typeof ProviderMap> = {
title: 'Organisms/ProviderMap',
component: ProviderMap,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Google Map showing provider pins with click-to-open popup. Uses the MapPin atom for markers and the MapPopup molecule for the popup card. Auto-fits the viewport to all providers with coords. Clicking a popup triggers `onSelectProvider`.',
},
},
},
decorators: [
(Story) => (
<Box sx={{ width: '100vw', height: '100vh', display: 'flex' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderMap>;
// Cast: DemoProvider adds `tier` over ProviderData, structural subset for the map
const providers = demoProviders as ProviderData[];
// ────────────────────────────────────────────────────────────────────────────
/** All 7 demo providers with real NSW/QLD coordinates. Map fits bounds across them. */
export const Default: Story = {
args: {
providers,
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** One provider pre-selected — its pin renders in the active (inverted) state. */
export const WithSelectedProvider: Story = {
args: {
providers,
selectedProviderId: 'parsons',
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** Interactive demo — clicking a popup clears/re-selects as if navigating. */
export const InteractiveSelection: Story = {
render: (args) => {
const StoryWrapper = () => {
const [selected, setSelected] = useState<string | null>(null);
return (
<ProviderMap
{...args}
selectedProviderId={selected}
onSelectProvider={(id) => setSelected((prev) => (prev === id ? null : id))}
/>
);
};
return <StoryWrapper />;
},
args: {
providers,
onSelectProvider: () => {},
},
};
/** Providers without coords — falls back to the "Map unavailable" empty state. */
export const NoCoords: Story = {
args: {
providers: providers.map(({ coords: _omit, ...p }) => p),
onSelectProvider: () => {},
},
};
/** No API key supplied — renders the empty state without attempting to load Google Maps. */
export const NoApiKey: Story = {
args: {
providers,
apiKey: '',
onSelectProvider: () => {},
},
};
/** Single provider — map centres on that coord with zoom 13. */
export const SingleProvider: Story = {
args: {
providers: [providers[0]],
onSelectProvider: () => {},
},
};
/** Mixed — some providers with coords, some without. Only those with coords render. */
export const PartialCoords: Story = {
args: {
providers: providers.map((p, i) => (i % 2 === 0 ? p : { ...p, coords: undefined })),
onSelectProvider: () => {},
},
};

View File

@@ -0,0 +1,549 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import {
APIProvider,
Map as GoogleMap,
AdvancedMarker,
useMap,
useMapsLibrary,
} from '@vis.gl/react-google-maps';
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
import { MapPin } from '../../atoms/MapPin';
import { ClusterMarker } from '../../atoms/ClusterMarker';
import { MapPopup } from '../../molecules/MapPopup';
import { ClusterPopup } from '../../molecules/ClusterPopup';
import { Typography } from '../../atoms/Typography';
import type { ProviderData } from '../../pages/ProvidersStep';
// ─── Constants ──────────────────────────────────────────────────────────────
/** Sydney — fallback centre when no providers have coords and no default supplied */
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
const FALLBACK_ZOOM = 5;
/** Google Maps requires a mapId for AdvancedMarker support */
const MAP_ID = 'fa-provider-map';
/** fitBounds padding (applied as google.maps.Padding) */
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
/** Screen-pixel radius at which nearby pins collapse into a cluster */
const CLUSTER_GRID_SIZE = 70;
/** Zoom level above which clustering is disabled (pins show individually) */
const CLUSTER_MAX_ZOOM = 13;
/** Zoom level the map animates to on cluster drill-in (street-level, past
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
const DRILL_IN_ZOOM = 15;
/** Exit-animation duration for popups on close — keep in sync with the
* transition values set on MapPopup/ClusterPopup. */
const POPUP_EXIT_MS = 180;
// ─── Types ──────────────────────────────────────────────────────────────────
/** Shape of the currently-active provider or cluster selection, emitted to
* callers that opt into external popup rendering (see `externalisePopups`). */
export interface ProviderMapActiveState {
/** Active single provider, if a pin was tapped (or a cluster row drilled into) */
provider: ProviderData | null;
/** Active cluster, if a cluster marker was tapped and no row has been drilled into */
cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null;
/** True while the exit animation is running — callers may want to mirror it */
exiting: boolean;
}
/** Imperative handle exposed via ref. Used when rendering popups externally. */
export interface ProviderMapHandle {
/** Close the currently-active popup (animated). No-op if nothing is open. */
clearActive: () => void;
/** Pan + zoom the map to a provider's coords and set them as the active
* single-provider selection. Equivalent to a cluster-row tap. */
drillIntoProvider: (id: string) => void;
}
/** Props for the FA ProviderMap organism */
export interface ProviderMapProps {
/** Providers to render as pins. Providers without coords are filtered out silently. */
providers: ProviderData[];
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
selectedProviderId?: string | null;
/** Called when the user clicks through a popup — usually triggers navigation */
onSelectProvider: (id: string) => void;
/** Initial map centre — used only when no providers have coords */
defaultCenter?: { lat: number; lng: number };
/** Initial zoom — used only when no providers have coords */
defaultZoom?: number;
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
apiKey?: string;
/** When true, suppress the organism's own MapPopup + ClusterPopup rendering.
* The active state is still tracked internally (pins still hide when active)
* and emitted via `onActiveChange` so callers can render a drawer, sheet,
* or other external container. Used by the mobile map-first layout. */
externalisePopups?: boolean;
/** Fires whenever the active provider/cluster state changes. Paired with
* `externalisePopups` — the caller uses this to drive external UI. */
onActiveChange?: (state: ProviderMapActiveState) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
interface ActiveCluster {
providers: ProviderData[];
position: google.maps.LatLngLiteral;
}
// ─── Internal components ────────────────────────────────────────────────────
/**
* Fits the map to the bounds of all providers with coords. Runs whenever the
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
*/
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
const map = useMap();
React.useEffect(() => {
if (!map) return;
const withCoords = providers.filter((p) => p.coords);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setCenter(withCoords[0].coords!);
map.setZoom(13);
return;
}
const bounds = new window.google.maps.LatLngBounds();
withCoords.forEach((p) => bounds.extend(p.coords!));
map.fitBounds(bounds, BOUNDS_PADDING);
}, [map, providers]);
return null;
};
/**
* Captures the Google Map instance into a parent ref so imperative
* actions (panTo, setZoom) can be triggered from outside the Map context.
*/
const MapRefCapture: React.FC<{
mapRef: React.MutableRefObject<google.maps.Map | null>;
}> = ({ mapRef }) => {
const map = useMap();
React.useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
/**
* Imperative marker layer — builds AdvancedMarker instances with React
* content, groups them via MarkerClusterer, and rebuilds whenever the
* visible provider set changes.
*
* Providers listed in `hiddenIds` are excluded from the map (their popup is
* currently showing instead).
*/
const MarkerLayer: React.FC<{
providers: ProviderData[];
hiddenIds: Set<string>;
onPinClick: (id: string) => void;
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
}> = ({ providers, hiddenIds, onPinClick, onClusterClick }) => {
const map = useMap();
const markerLibrary = useMapsLibrary('marker');
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
// every marker) when the parent passes fresh arrow-function references.
const onPinClickRef = React.useRef(onPinClick);
const onClusterClickRef = React.useRef(onClusterClick);
React.useEffect(() => {
onPinClickRef.current = onPinClick;
onClusterClickRef.current = onClusterClick;
}, [onPinClick, onClusterClick]);
React.useEffect(() => {
if (!map || !markerLibrary) return;
const roots: Root[] = [];
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
const markers = providers
.filter((p) => p.coords && !hiddenIds.has(p.id))
.map((p) => {
const el = document.createElement('div');
const root = createRoot(el);
// MapPin's own onClick stays for keyboard a11y (Enter/Space via its
// onKeyDown). stopPropagation guards against the DOM click bubbling
// to the Map's onClick and closing the popup the same frame it opens.
root.render(
<MapPin
name={p.name}
price={p.startingPrice}
verified={p.verified}
onClick={(e) => {
e.stopPropagation();
onPinClickRef.current(p.id);
}}
/>,
);
roots.push(root);
const marker = new markerLibrary.AdvancedMarkerElement({
position: p.coords,
content: el,
gmpClickable: true,
});
// Also listen at the Google Maps level + stop the GMaps event so
// Map's onClick can't fire when a pin is clicked via mouse. Safe to
// fire twice with keyboard — handlePinClick is idempotent.
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
event.stop();
onPinClickRef.current(p.id);
});
markerToProvider.set(marker, p);
return marker;
});
const clusterer = new MarkerClusterer({
map,
markers,
algorithm: new GridAlgorithm({
maxZoom: CLUSTER_MAX_ZOOM,
gridSize: CLUSTER_GRID_SIZE,
}),
// Override the library's default "zoom to fit cluster" on click —
// we open the cluster popup instead. The event shape the library
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
// click doesn't also fire Map.onClick and clear our state.
onClusterClick: (event, cluster) => {
const anyEvent = event as unknown as {
stop?: () => void;
stopPropagation?: () => void;
domEvent?: { stopPropagation?: () => void };
};
anyEvent.stop?.();
anyEvent.stopPropagation?.();
anyEvent.domEvent?.stopPropagation?.();
const providersInCluster = cluster.markers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const clusterPosition =
cluster.position instanceof window.google.maps.LatLng
? cluster.position.toJSON()
: (cluster.position as google.maps.LatLngLiteral);
onClusterClickRef.current(providersInCluster, clusterPosition);
},
renderer: {
render: ({ count, position, markers: clusterMarkers }) => {
const providersInCluster = clusterMarkers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const hasVerified = providersInCluster.some((p) => p.verified);
const el = document.createElement('div');
const root = createRoot(el);
// Visual only — click is handled at the MarkerClusterer level above.
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
roots.push(root);
return new markerLibrary.AdvancedMarkerElement({
position,
content: el,
gmpClickable: true,
});
},
},
});
return () => {
clusterer.clearMarkers();
// Defer unmount so React doesn't warn about unmounting during render.
setTimeout(() => {
roots.forEach((r) => r.unmount());
}, 0);
};
}, [map, markerLibrary, providers, hiddenIds]);
return null;
};
/** Empty-state shown when no API key is configured or no providers have coords. */
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
Map unavailable
</Typography>
<Typography variant="caption" color="text.secondary">
{reason === 'no-key'
? 'Google Maps API key not configured.'
: 'No provider locations to display.'}
</Typography>
</Box>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Google Map showing provider pins with clustering + click-to-open popups.
*
* **Interaction model:**
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
* coord. Clicking the map background reverts.
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
* (13) or below. Zoom in past that and every pin shows individually.
* - Clicking a cluster opens a `ClusterPopup` listing its providers
* (verified-first). Clicking a row **pans and zooms the map to that
* provider's location** (zoom 15 = past the clustering ceiling, so the
* other cluster members separate into their own pins around the selected
* one) and opens that provider's `MapPopup`. The cluster state is cleared
* — there's no back-to-list; the user's path forward is clear rather than
* hierarchical.
*
* **Viewport:** auto-fits to include every provider with coords on load and
* when the list changes. Single-provider maps centre with zoom 13.
*
* **Empty states:** if no API key is set or no providers have coords, a
* subtle empty state renders in place (no throw).
*
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
* (molecules). Clustering via `@googlemaps/markerclusterer`.
*/
export const ProviderMap = React.forwardRef<ProviderMapHandle, ProviderMapProps>(
(
{
providers,
selectedProviderId,
onSelectProvider,
defaultCenter = FALLBACK_CENTER,
defaultZoom = FALLBACK_ZOOM,
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
externalisePopups = false,
onActiveChange,
sx,
},
ref,
) => {
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
const [exiting, setExiting] = React.useState(false);
const mapRef = React.useRef<google.maps.Map | null>(null);
const exitTimerRef = React.useRef<number | null>(null);
// Helper: cancel any pending exit timer so rapid clicks don't clobber
// newly-opened popups with a leftover clear from a previous close.
const cancelExit = React.useCallback(() => {
if (exitTimerRef.current) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
setExiting(false);
}, []);
React.useEffect(
() => () => {
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
},
[],
);
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
const activeProvider = React.useMemo(
() =>
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
[withCoords, effectiveProviderId],
);
// Pins hidden from the map (because their popup is showing instead).
const hiddenIds = React.useMemo(() => {
const s = new Set<string>();
if (effectiveProviderId) s.add(effectiveProviderId);
if (activeCluster) {
activeCluster.providers.forEach((p) => s.add(p.id));
}
return s;
}, [effectiveProviderId, activeCluster]);
const handlePinClick = React.useCallback(
(id: string) => {
cancelExit();
setActiveProviderId(id);
setActiveCluster(null);
},
[cancelExit],
);
const handleClusterClick = React.useCallback(
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
cancelExit();
setActiveProviderId(null);
setActiveCluster({ providers: clusterProviders, position });
},
[cancelExit],
);
/** Shared close path — animate the popup out (exiting=true triggers the
* CSS transition in MapPopup / ClusterPopup), then actually clear state
* after the transition completes so the pin can fade back in. */
const closeWithExit = React.useCallback(() => {
if (!activeProviderId && !activeCluster) return;
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
setExiting(true);
exitTimerRef.current = window.setTimeout(() => {
setActiveProviderId(null);
setActiveCluster(null);
setExiting(false);
exitTimerRef.current = null;
}, POPUP_EXIT_MS);
}, [activeProviderId, activeCluster]);
const handleMapClick = closeWithExit;
const handleCloseCluster = closeWithExit;
// Emit active-state changes when the caller is rendering popups externally.
const onActiveChangeRef = React.useRef(onActiveChange);
React.useEffect(() => {
onActiveChangeRef.current = onActiveChange;
}, [onActiveChange]);
React.useEffect(() => {
onActiveChangeRef.current?.({
provider: activeProvider,
cluster: activeCluster
? {
providers: activeCluster.providers,
position: {
lat: activeCluster.position.lat,
lng: activeCluster.position.lng,
},
}
: null,
exiting,
});
}, [activeProvider, activeCluster, exiting]);
/** Cluster list → single-provider drill-in.
* Pans + zooms the map to the provider's coords (zoom 15 = past
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
* pins around the selected one), then clears the cluster state and
* opens the single-provider popup. */
const handleDrillIntoProvider = React.useCallback(
(id: string) => {
cancelExit();
const provider = withCoords.find((p) => p.id === id);
if (provider?.coords && mapRef.current) {
mapRef.current.panTo(provider.coords);
mapRef.current.setZoom(DRILL_IN_ZOOM);
}
setActiveProviderId(id);
setActiveCluster(null);
},
[withCoords, cancelExit],
);
// Imperative handle for external callers (drawer close, cluster-row tap).
React.useImperativeHandle(
ref,
() => ({
clearActive: closeWithExit,
drillIntoProvider: handleDrillIntoProvider,
}),
[closeWithExit, handleDrillIntoProvider],
);
const rootSx = [
{
position: 'relative' as const,
display: 'flex',
flex: 1,
minHeight: 300,
width: '100%',
overflow: 'hidden',
bgcolor: 'var(--fa-color-surface-cool)',
},
...(Array.isArray(sx) ? sx : [sx]),
];
// Empty states
if (!apiKey) {
return (
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-key" />
</Box>
);
}
if (withCoords.length === 0) {
return (
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-coords" />
</Box>
);
}
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<APIProvider apiKey={apiKey}>
<GoogleMap
defaultCenter={defaultCenter}
defaultZoom={defaultZoom}
mapId={MAP_ID}
disableDefaultUI
zoomControl
gestureHandling="greedy"
onClick={handleMapClick}
style={{ width: '100%', height: '100%' }}
>
<FitBounds providers={withCoords} />
<MapRefCapture mapRef={mapRef} />
<MarkerLayer
providers={withCoords}
hiddenIds={hiddenIds}
onPinClick={handlePinClick}
onClusterClick={handleClusterClick}
/>
{/* Internal popups — skipped when caller externalises them (e.g.
mobile drawer). Active state still flows via onActiveChange. */}
{!externalisePopups && activeProvider && (
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
<MapPopup
name={activeProvider.name}
imageUrl={activeProvider.imageUrl}
price={activeProvider.startingPrice}
location={activeProvider.location}
rating={activeProvider.rating}
verified={activeProvider.verified}
exiting={exiting}
onClick={() => onSelectProvider(activeProvider.id)}
/>
</AdvancedMarker>
)}
{/* Cluster list popup — shown while a cluster is active and no
provider has been drilled into. Drilling clears activeCluster,
which swaps this for the single-provider popup above. */}
{!externalisePopups && activeCluster && !activeProviderId && (
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
<ClusterPopup
providers={activeCluster.providers.map((p) => ({
id: p.id,
name: p.name,
location: p.location,
verified: p.verified,
rating: p.rating,
startingPrice: p.startingPrice,
}))}
exiting={exiting}
onSelectProvider={handleDrillIntoProvider}
onClose={handleCloseCluster}
/>
</AdvancedMarker>
)}
</GoogleMap>
</APIProvider>
</Box>
);
},
);
ProviderMap.displayName = 'ProviderMap';
export default ProviderMap;

View File

@@ -0,0 +1,6 @@
export {
ProviderMap,
type ProviderMapProps,
type ProviderMapHandle,
type ProviderMapActiveState,
} from './ProviderMap';

View File

@@ -122,7 +122,7 @@ const pkgMackay: ComparisonPackage = {
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
name: 'Mackay Family Funeral Directors & Cremation Services',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,

View File

@@ -1,14 +1,21 @@
import React, { useId, useState, useRef, useCallback } from 'react';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Link } from '../../atoms/Link';
import { WizardLayout } from '../../templates/WizardLayout';
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
import {
ComparisonTable,
COMPARISON_TABLE_COL_WIDTH,
type ComparisonPackage,
} from '../../organisms/ComparisonTable';
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
@@ -113,27 +120,147 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Natural table width = (row-label col) + (pkg col × n), matches page header maxWidth.
// Page header container reaches this same width so the table's left edge aligns
// with the page header's left edge when the table overflows horizontally.
const tableNaturalWidth = COMPARISON_TABLE_COL_WIDTH * (allPackages.length + 1);
const pageMaxWidth = COMPARISON_TABLE_COL_WIDTH * 4; // fits 3-package case flush
// Matching horizontal padding between the page header container and the
// table-zone spacers keeps inner-content left edges aligned on all viewports.
const edgePadding = { xs: 16, md: 24 };
return (
<Box ref={ref} sx={sx}>
<WizardLayout
variant="wide-form"
variant={isMobile ? 'wide-form' : 'bleed'}
navigation={navigation}
showBackLink
showBackLink={isMobile}
backLabel="Back"
onBack={onBack}
>
{/* Page header with Share/Print actions */}
<Box sx={{ mb: { xs: 3, md: 5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
{!isMobile && (
<>
{/* Page header zone — centred, bounded to the table's natural width */}
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Box
sx={{
width: '100%',
maxWidth: pageMaxWidth,
px: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
pt: { xs: 2, md: 3 },
pb: { xs: 3, md: 5 },
}}
>
<Link
component="button"
onClick={onBack}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'text.secondary',
fontSize: '0.875rem',
fontWeight: 500,
mb: 2,
'&:hover': { color: 'text.primary' },
}}
>
<ArrowBackIcon sx={{ fontSize: 18 }} />
Back
</Link>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
</Box>
<Divider />
{/* Table zone — width-matching spacers centre the table when room
allows, collapse to the minimum when table is wider than
viewport so overflow extends rightward from the page's
content column. */}
<Box
sx={{
display: 'flex',
width: 'max-content',
minWidth: '100%',
py: { xs: 3, md: 5 },
}}
>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
<Box sx={{ flexShrink: 0, width: tableNaturalWidth }}>
<ComparisonTable
packages={allPackages}
onArrange={onArrange}
onRemove={onRemove}
/>
</Box>
<Box
aria-hidden
sx={{
flex: 1,
minWidth: { xs: `${edgePadding.xs}px`, md: `${edgePadding.md}px` },
}}
/>
</Box>
</>
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
<Box sx={{ mb: 3 }}>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
@@ -142,50 +269,21 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
</Typography>
</Box>
{/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
<Divider sx={{ mb: 3 }} />
{/* Desktop: ComparisonTable */}
{!isMobile && (
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package + price */}
<Typography
id="comparison-rail-heading"
variant="label"
component="h2"
sx={{ fontWeight: 600, display: 'block', mb: 1.5 }}
>
Choose a package to view
</Typography>
<Box
ref={railRef}
role="tablist"
id={tablistId}
aria-label="Packages to compare"
aria-labelledby="comparison-rail-heading"
sx={{
display: 'flex',
gap: 1.5,
@@ -193,8 +291,7 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
py: 2,
px: 2,
mx: -2,
mt: 1,
mb: 3,
mb: 1.5,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch',
@@ -216,6 +313,54 @@ export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPagePro
))}
</Box>
{/* Dot indicator — position + count. Purely visual supplement;
the tab rail above is the accessible navigation, so dots
are aria-hidden and skipped by keyboard tab-order. */}
<Box
aria-hidden="true"
sx={{
display: 'flex',
justifyContent: 'center',
gap: 0.5,
mb: 3,
}}
>
{allPackages.map((_, idx) => {
const isActive = idx === activeTabIdx;
return (
<Box
key={idx}
component="button"
type="button"
tabIndex={-1}
onClick={() => handleTabClick(idx)}
sx={{
appearance: 'none',
border: 0,
background: 'transparent',
cursor: 'pointer',
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& > span': {
display: 'block',
width: isActive ? 24 : 8,
height: 8,
borderRadius: 4,
bgcolor: isActive
? 'var(--fa-color-brand-600)'
: 'var(--fa-color-neutral-300)',
transition: 'width 0.2s ease, background-color 0.2s ease',
},
}}
>
<span />
</Box>
);
})}
</Box>
{activePackage && (
<Box
role="tabpanel"

View File

@@ -1,9 +1,9 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { PackagesStep } from './PackagesStep';
import type { PackageData, PackagesStepProvider } from './PackagesStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
import { PackagesStep } from './PackagesStep';
import type { NearbyVerifiedProvider, PackageData, PackagesStepProvider } from './PackagesStep';
import { Navigation } from '../../organisms/Navigation';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -35,10 +35,19 @@ const nav = (
/>
);
const mockProvider: PackagesStepProvider = {
// ─── Mock data ───────────────────────────────────────────────────────────────
const verifiedProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
rating: 4.6,
reviewCount: 7,
};
const unverifiedProvider: PackagesStepProvider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
rating: 4.6,
reviewCount: 7,
};
@@ -147,6 +156,119 @@ const otherPackages: PackageData[] = [
},
];
const manyOtherPackages: PackageData[] = [
...otherPackages,
{
id: 'memorial',
name: 'Memorial Service',
price: 2400,
description: 'A celebration-of-life service without burial or cremation on the same day.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Professional Service Fee', price: 1200 },
{ name: 'Venue coordination', price: 600 },
{ name: 'Memorial book', price: 100 },
],
},
],
total: 2400,
},
{
id: 'graveside',
name: 'Graveside Service',
price: 2900,
description: 'A simple graveside committal, ideal for smaller family gatherings.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Professional Mortuary Care', price: 1000 },
{ name: 'Professional Service Fee', price: 1100 },
{ name: 'Cemetery coordination', price: 400 },
],
},
],
total: 2900,
},
{
id: 'prepaid-basic',
name: 'Prepaid Basic Plan',
price: 3600,
description: 'Lock in todays price for a basic cremation package, paid over 12 months.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Locked-in pricing', price: 0, priceLabel: 'Complimentary' },
{ name: 'Professional Service Fee', price: 1200 },
{ name: 'Professional Mortuary Care', price: 1000 },
],
},
],
total: 3600,
},
];
const nearbyVerifiedProviders: NearbyVerifiedProvider[] = [
{
id: 'rankins',
name: 'Rankins Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Warrawong, NSW',
startingPrice: 2450,
rating: 4.8,
reviewCount: 23,
},
{
id: 'mannings',
name: 'Mannings Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Bega, NSW',
startingPrice: 1950,
rating: 4.7,
reviewCount: 42,
},
{
id: 'killick',
name: 'Killick Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Kingaroy, QLD',
startingPrice: 3100,
rating: 4.9,
reviewCount: 15,
},
{
id: 'mackay',
name: 'Mackay Family Funerals',
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
location: 'Ourimbah, NSW',
startingPrice: 2780,
rating: 4.6,
reviewCount: 19,
},
];
const tier2Packages: PackageData[] = [
{
id: 't2-standard',
name: 'Standard Funeral Service',
price: 5200,
description:
'A full-service package based on publicly available information. Breakdown not available — make an enquiry to confirm what is included.',
sections: [],
},
{
id: 't2-basic',
name: 'Basic Cremation',
price: 3400,
description:
'An entry-level package based on publicly available information. Pricing is indicative only.',
sections: [],
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof PackagesStep> = {
@@ -161,45 +283,24 @@ const meta: Meta<typeof PackagesStep> = {
export default meta;
type Story = StoryObj<typeof PackagesStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
// ─── Verified ────────────────────────────────────────────────────────────────
/** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={mockProvider}
packages={matchedPackages}
otherPackages={otherPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package already selected — detail panel visible */
export const WithSelection: Story = {
/** Verified provider — matching packages + up to 3 other packages from the same provider */
export const Verified: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={mockProvider}
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
otherPackages={otherPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
@@ -207,21 +308,127 @@ export const WithSelection: Story = {
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
// ─── Verified — with "See all" link ─────────────────────────────────────────
/** All packages match filters — no "Other packages" section */
export const AllMatching: Story = {
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
export const VerifiedWithManyOtherPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: manyOtherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onSeeAllPackages={() => alert('Route to showAllFromProvider variant')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── "Show all from provider" variant ───────────────────────────────────────
/** Flat "All packages from [Provider]" view — no grouping, selected package preserved */
export const AllFromProvider: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
const allPackages = [...matchedPackages, ...manyOtherPackages];
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={allPackages}
showAllFromProvider
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Tier 3 (itemised breakdown) ────────────────────────────────────────────
/** Tier 3 unverified — itemised breakdown + "Make an enquiry" + nearby verified alternatives */
export const Tier3: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<PackagesStep
provider={unverifiedProvider}
providerTier="tier3"
packages={matchedPackages}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Tier 2 (price only, no breakdown) ──────────────────────────────────────
/** Tier 2 unverified — price only, detail panel shows "Itemised Pricing Unavailable" */
export const Tier2: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('t2-standard');
return (
<PackagesStep
provider={unverifiedProvider}
providerTier="tier2"
packages={tier2Packages}
secondaryList={{ kind: 'nearby-verified', providers: nearbyVerifiedProviders }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onCompare={() => alert('Open compare view')}
onNearbyProviderClick={(id) => alert(`Route to verified provider: ${id}`)}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Edge cases ──────────────────────────────────────────────────────────────
/** No selection yet — empty detail panel */
export const NoSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={mockProvider}
packages={[...matchedPackages, ...otherPackages]}
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
@@ -229,7 +436,27 @@ export const AllMatching: Story = {
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Verified provider with no "other packages" — primary list only */
export const VerifiedNoSecondary: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
@@ -238,13 +465,15 @@ export const PrePlanning: Story = {
return (
<PackagesStep
provider={mockProvider}
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
otherPackages={otherPackages}
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onCompare={() => alert('Open compare view')}
onProviderClick={() => alert('Open provider profile (future)')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
@@ -253,16 +482,15 @@ export const PrePlanning: Story = {
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
/** Validation error */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<PackagesStep
provider={mockProvider}
provider={verifiedProvider}
providerTier="verified"
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}

View File

@@ -1,68 +1,125 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { MiniCard } from '../../molecules/MiniCard';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
// ─── Types ───────────────────────────────────────────────────────────────────
export type {
PackageData,
PackagesStepProvider,
NearbyVerifiedProvider,
ProviderTier,
SecondaryList,
} from './types';
/** Provider summary for the compact card */
export interface PackagesStepProvider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
// ─── Tier copy map ───────────────────────────────────────────────────────────
interface TierCopy {
heading: string;
subheading: (isPrePlanning: boolean) => string;
arrangeLabel: string;
priceDisclaimer?: string;
itemizedUnavailable: boolean;
emptyDetailMessage: string;
}
/** Package data for the selection list */
export interface PackageData {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
total?: number;
/** Extra items section (after total) */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
const TIER_COPY: Record<ProviderTier, TierCopy> = {
verified: {
heading: 'Choose a funeral package',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
: 'Each package includes a set of services. You can customise your selections in the next steps.',
arrangeLabel: 'Make Arrangement',
itemizedUnavailable: false,
emptyDetailMessage: "Select a package to see what's included.",
},
tier3: {
heading: 'Explore available packages',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
arrangeLabel: 'Make an enquiry',
priceDisclaimer:
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
itemizedUnavailable: false,
emptyDetailMessage: "Select a package to see what's included.",
},
tier2: {
heading: 'Explore available packages',
subheading: (isPrePlanning) =>
isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
arrangeLabel: 'Make an enquiry',
priceDisclaimer:
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
itemizedUnavailable: true,
emptyDetailMessage: 'Select a package to see more details.',
},
};
// Show at most this many "other packages from this provider" inline before
// switching to "top N + See all →" behaviour.
const SAME_PROVIDER_INLINE_LIMIT = 3;
// Max number of verified provider MiniCards in the "Similar packages from
// verified providers" grid on unverified pages.
const NEARBY_VERIFIED_LIMIT = 4;
// ─── Props ───────────────────────────────────────────────────────────────────
/** Props for the PackagesStep page component */
export interface PackagesStepProps {
/** Provider summary shown at top of the list panel */
/** Provider shown at the top of the list panel */
provider: PackagesStepProvider;
/** Packages matching the user's filters from the previous step */
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */
providerTier: ProviderTier;
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
packages: PackageData[];
/** Other packages from this provider that didn't match filters (shown in secondary group) */
otherPackages?: PackageData[];
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
secondaryList?: SecondaryList;
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
/** Callback when a primary-list package is selected (or cleared via mobile back) */
onSelectPackage: (id: string | null) => void;
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
onArrange: () => void;
/** Callback when the provider card is clicked (opens provider profile popup) */
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
onCompare?: () => void;
/** Whether the currently-selected package is already in the comparison
* basket. When true, PackageDetail swaps its Compare button into the
* "In comparison" selected-state (inert; removal via CompareBar). */
isSelectedPackageInCart?: boolean;
/** Callback when a nearby-verified provider card is clicked (route change to that provider's PackagesStep) */
onNearbyProviderClick?: (id: string) => void;
/**
* Callback when "See all N packages from [Provider]" is clicked.
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
* Only used when secondaryList.kind === 'same-provider-more' and list length > 3.
*/
onSeeAllPackages?: () => void;
/** Callback when the provider card is clicked (future: opens provider profile) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/**
* When true, renders the "All packages from [Provider]" variant:
* flat list, no grouping, no secondary list, no "Matching your preferences" heading.
* Caller passes the full package list in `packages`.
*/
showAllFromProvider?: boolean;
/** Validation error */
error?: string;
/** Whether the arrange action is loading */
@@ -75,23 +132,61 @@ export interface PackagesStepProps {
sx?: SxProps<Theme>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Accent bar + label — used for both "Matching your preferences" and "Other packages from [X]". */
function GroupHeading({
label,
emphasis = 'primary',
}: {
label: string;
emphasis?: 'primary' | 'secondary';
}) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: emphasis === 'primary' ? 'primary.main' : 'text.secondary',
flexShrink: 0,
}}
/>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: emphasis === 'primary' ? 'text.primary' : 'text.secondary',
}}
>
{label}
</Typography>
</Box>
);
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 3 — Package selection page for the FA arrangement wizard.
* Package selection step — tier-aware, unified page component.
*
* List + Detail split layout. Left panel shows the selected provider
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
* Handles all three provider tiers (verified, tier3, tier2) via the
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
* itemised-unavailable state are derived from tier.
*
* Packages are split into two groups:
* - **Matching your preferences**: packages that matched the user's filters
* from the providers step
* - **Other packages from [Provider]**: remaining packages outside those
* filters, shown below a divider for passive discovery
* Left column layout varies by `secondaryList`:
* - `same-provider-more` (verified): primary "Matching your preferences"
* list + "Other packages from [Provider]" list. If >3 other packages,
* shows top 3 + "See all N packages from [Provider] →" link that routes
* to the same page with `showAllFromProvider`.
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
* from verified providers" 2-column MiniCard grid, capped at 4. Every
* card is verified by definition.
*
* Selecting a package reveals its detail. Clicking "Make Arrangement"
* on the detail panel triggers the ArrangementDialog (D-E).
* When `showAllFromProvider` is true, renders a flat "All packages from
* [Provider]" list with no grouping and no secondary list. The caller
* preserves `selectedPackageId` across this navigation.
*
* Pure presentation component — props in, callbacks out.
*
@@ -99,191 +194,290 @@ export interface PackagesStepProps {
*/
export const PackagesStep: React.FC<PackagesStepProps> = ({
provider,
providerTier,
packages,
otherPackages = [],
secondaryList,
selectedPackageId,
onSelectPackage,
onArrange,
onCompare,
isSelectedPackageInCart = false,
onNearbyProviderClick,
onSeeAllPackages,
onProviderClick,
onBack,
showAllFromProvider = false,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const allPackages = [...packages, ...otherPackages];
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
const hasOtherPackages = otherPackages.length > 0;
const copy = TIER_COPY[providerTier];
// Look up the selected package across BOTH the primary list and the
// same-provider-more secondary list — tapping "Premium Funeral Service"
// in the "Other packages from X" section should surface its detail too.
const selectedPackage =
packages.find((p) => p.id === selectedPackageId) ??
(secondaryList?.kind === 'same-provider-more'
? secondaryList.packages.find((p) => p.id === selectedPackageId)
: undefined);
const subheading = isPrePlanning
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
: 'Each package includes a set of services. You can customise your selections in the next steps.';
// Mobile drill-in: on mobile, the list is the default view — only when the
// user explicitly taps a package do we swap in the detail panel. This
// distinguishes "parent pre-selected first package for desktop auto-display"
// (which should NOT jump to detail on mobile) from "user tapped a package".
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [hasDrilledIn, setHasDrilledIn] = useState(false);
const mobileShowDetail = isMobile && hasDrilledIn && selectedPackageId != null;
const handleSelectPackage = (id: string | null) => {
setHasDrilledIn(id != null);
onSelectPackage(id);
};
useEffect(() => {
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
}, [mobileShowDetail]);
const handleLayoutBack = mobileShowDetail ? () => handleSelectPackage(null) : onBack;
const layoutBackLabel = mobileShowDetail ? 'Back to packages' : 'Back';
// Secondary list suppressed in "show all" mode.
const activeSecondaryList = showAllFromProvider ? undefined : secondaryList;
const hasSecondary = Boolean(activeSecondaryList);
// For same-provider-more, show top N inline; surface "See all" when over limit.
const sameProviderPackages =
activeSecondaryList?.kind === 'same-provider-more' ? activeSecondaryList.packages : [];
const sameProviderOverflow = sameProviderPackages.length > SAME_PROVIDER_INLINE_LIMIT;
const sameProviderVisible = sameProviderOverflow
? sameProviderPackages.slice(0, SAME_PROVIDER_INLINE_LIMIT)
: sameProviderPackages;
const heading = showAllFromProvider ? `All packages from ${provider.name}` : copy.heading;
const subheading = showAllFromProvider
? `Every package ${provider.name} offers, including those outside your preferences.`
: copy.subheading(isPrePlanning);
const primaryListAriaLabel = showAllFromProvider
? `All packages from ${provider.name}`
: 'Funeral packages';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
backLabel={layoutBackLabel}
onBack={handleLayoutBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
arrangeDisabled={loading}
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see what&apos;s included.
</Typography>
</Box>
)
}
>
{/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Choose a funeral package
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Matching packages ─── */}
{hasOtherPackages && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
display: {
xs: mobileShowDetail ? 'block' : 'none',
md: 'block',
},
}}
>
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'primary.main',
flexShrink: 0,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Matching your preferences
</Typography>
</Box>
)}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Other packages (passive discovery) ─── */}
{hasOtherPackages && (
<>
<Divider sx={{ mb: 2 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 2,
}}
>
{selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
onCompare={onCompare}
inCart={isSelectedPackageInCart}
arrangeDisabled={loading}
arrangeLabel={copy.arrangeLabel}
priceDisclaimer={copy.priceDisclaimer}
itemizedUnavailable={copy.itemizedUnavailable}
/>
) : (
<Box
sx={{
width: 3,
height: 20,
borderRadius: 1,
bgcolor: 'text.secondary',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Other packages from {provider.name}
</Typography>
</Box>
<Box
role="radiogroup"
aria-label={`Other packages from ${provider.name}`}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
{copy.emptyDetailMessage}
</Typography>
</Box>
)}
</Box>
}
>
{/* List column — hidden on mobile when a package is selected (drill-in) */}
<Box
sx={{
display: {
xs: mobileShowDetail ? 'none' : 'block',
md: 'block',
},
}}
>
{/* Provider compact card */}
<Box sx={{ mb: 6 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading + subheading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
{heading}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
{subheading}
</Typography>
{/* Error */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{otherPackages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
</Box>
</>
)}
{error}
</Typography>
)}
{/* ─── Primary packages ─── */}
{/* Show "Matching your preferences" heading only when a secondary list follows */}
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
<Box
role="radiogroup"
aria-label={primaryListAriaLabel}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => handleSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Secondary: same-provider-more ─── */}
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
<>
<Divider sx={{ my: 8 }} />
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
<Box
role="radiogroup"
aria-label={`Other packages from ${provider.name}`}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
mb: sameProviderOverflow ? 2 : 3,
opacity: 0.85,
}}
>
{sameProviderVisible.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => handleSelectPackage(pkg.id)}
/>
))}
</Box>
{sameProviderOverflow && onSeeAllPackages && (
<Box sx={{ mb: 3 }}>
<Link
component="button"
type="button"
onClick={onSeeAllPackages}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
fontWeight: 600,
}}
>
See {sameProviderPackages.length - SAME_PROVIDER_INLINE_LIMIT} more packages from
this provider
<ArrowForwardIcon sx={{ fontSize: 16 }} aria-hidden />
</Link>
</Box>
)}
</>
)}
{/* ─── Secondary: nearby-verified ─── */}
{activeSecondaryList?.kind === 'nearby-verified' &&
activeSecondaryList.providers.length > 0 && (
<>
<Divider sx={{ my: 8 }} />
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'primary.main', mt: '3px' }}
aria-hidden
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers
</Typography>
</Box>
<Box
aria-label="Similar packages from verified providers"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
gap: 2,
mb: 3,
}}
>
{activeSecondaryList.providers.slice(0, NEARBY_VERIFIED_LIMIT).map((p) => (
<MiniCard
key={p.id}
title={p.name}
imageUrl={p.imageUrl}
verified
price={p.startingPrice}
location={p.location}
rating={p.rating}
onClick={onNearbyProviderClick ? () => onNearbyProviderClick(p.id) : undefined}
/>
))}
</Box>
</>
)}
</Box>
</WizardLayout>
);
};

View File

@@ -0,0 +1,105 @@
import type { PackageSection } from '../../organisms/PackageDetail';
// ─── Tier ────────────────────────────────────────────────────────────────────
/**
* Provider tier — drives header copy, CTA label, disclaimer text, and
* whether the PackageDetail panel shows an itemised breakdown.
*
* - `verified`: Paid-listing provider. Full data, "Make Arrangement" CTA.
* - `tier3`: Unverified provider with itemised breakdown scraped from public info.
* - `tier2`: Unverified provider with total price only (no itemised breakdown).
*/
export type ProviderTier = 'verified' | 'tier3' | 'tier2';
// ─── Provider ────────────────────────────────────────────────────────────────
export interface PackagesStepProvider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Hero image — typically only supplied for verified providers */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
// ─── Package data ────────────────────────────────────────────────────────────
/**
* Package data for the selection list.
*
* For `tier2` providers, callers should pass `sections: []` (and optionally
* omit `total`); the detail panel switches to "Itemised Pricing Unavailable"
* automatically based on the `providerTier` prop.
*/
export interface PackageData {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description shown on the option card */
description?: string;
/** Line-item sections for the detail panel (empty for tier2) */
sections: PackageSection[];
/** Total price shown between main sections and extras */
total?: number;
/** Extra-cost items shown after the total */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/**
* A verified provider surfaced on an unverified provider's PackagesStep.
*
* By definition every entry in this list is verified — the section is a
* curated "here are the real partners near you" promotion — so there is no
* `verified` flag on the data shape. Components that render this list pass
* a hard-coded `verified={true}` to their card.
*/
export interface NearbyVerifiedProvider {
/** Provider ID — routes to `/providers/:id/packages` */
id: string;
/** Provider name */
name: string;
/** Hero image URL (verified providers always have one) */
imageUrl: string;
/** Location (suburb, state) */
location: string;
/** Starting price — formatted as "From $X" on the card */
startingPrice: number;
/** Average rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
// ─── Secondary list ──────────────────────────────────────────────────────────
/**
* Discriminated union for the second list below the primary packages.
*
* - `same-provider-more`: Other packages from the same (verified) provider.
* Rendered as a ServiceOption list. If more than 3, the list shows the
* first 3 + a "See all N packages from [Provider]" link that navigates
* to the same PackagesStep with preference filters off.
* - `nearby-verified`: Verified providers promoted on unverified-tier pages
* under the heading "Similar packages from verified providers". Rendered
* as a 2-col MiniCard grid capped at 4. Clicking a card routes to that
* provider's PackagesStep.
*/
export type SecondaryList =
| {
kind: 'same-provider-more';
packages: PackageData[];
}
| {
kind: 'nearby-verified';
providers: NearbyVerifiedProvider[];
};

View File

@@ -5,19 +5,25 @@ import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel';
import Slider from '@mui/material/Slider';
import MenuItem from '@mui/material/MenuItem';
import Menu from '@mui/material/Menu';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import SwapVertIcon from '@mui/icons-material/SwapVert';
import useMediaQuery from '@mui/material/useMediaQuery';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel';
import { Button } from '../../atoms/Button';
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
import { HelpBar } from '../../molecules/HelpBar';
import { SortMenu } from '../../molecules/SortMenu';
import {
ProviderMap,
type ProviderMapActiveState,
type ProviderMapHandle,
} from '../../organisms/ProviderMap';
import { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography';
@@ -49,6 +55,8 @@ export interface ProviderData {
distanceKm?: number;
/** Brief description */
description?: string;
/** Geographic coordinates for map display */
coords?: { lat: number; lng: number };
}
/** A funeral type option for the filter */
@@ -165,8 +173,8 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price: Low to High' },
{ value: 'price_high', label: 'Price: High to Low' },
{ value: 'price_low', label: 'Price low to high' },
{ value: 'price_high', label: 'Price high to low' },
];
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
@@ -194,6 +202,98 @@ const chipWrapSx = {
gap: 1,
} as const;
/**
* Shared visual tokens for the ProvidersStep control chips. Search, Filters,
* Sort by, and the List/Map toggle all reference these so their outline /
* radius / fill / shadow / height read as one coherent set. Kept on the page
* (not promoted to a design-system-wide primitive) because this is a
* page-local "control cluster" pattern — Button and Input already own their
* own radii in the theme.
*/
const CONTROL_CHROME = {
height: 32,
borderColor: 'var(--fa-color-neutral-300)',
borderRadius: 'var(--fa-button-border-radius-default)',
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
} as const;
/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */
const controlButtonSx = {
height: CONTROL_CHROME.height,
bgcolor: CONTROL_CHROME.bgcolor,
borderColor: CONTROL_CHROME.borderColor,
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
textTransform: 'none',
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor },
'&:focus-visible': { outline: 'none' },
} as const;
/** sx for the FilterPanel wrapper — targets its internal trigger Button. */
const filterTriggerSx = {
'& .MuiButton-root': controlButtonSx,
} as const;
/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */
const controlToggleSx = {
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiToggleButton-root': {
height: CONTROL_CHROME.height,
px: 1.5,
py: 0,
textTransform: 'none',
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
borderColor: CONTROL_CHROME.borderColor,
bgcolor: CONTROL_CHROME.bgcolor,
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
} as const;
/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME.
* Absolute-anchors the end adornment (commit button) to the right edge —
* MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts the content in a
* `.MuiInputAdornment-positionEnd` (which is static by default), so the
* button slides left as chips/draft fill the input. `paddingRight` on the
* OutlinedInput reserves the lane so input content can't run under it. */
const controlInputSx = {
'& .MuiOutlinedInput-root': {
bgcolor: CONTROL_CHROME.bgcolor,
boxShadow: CONTROL_CHROME.boxShadow,
borderRadius: CONTROL_CHROME.borderRadius,
pr: 5,
position: 'relative',
},
'& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
},
} as const;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -242,8 +342,12 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
? '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.';
// ─── Local state ───
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
// ─── Mobile map-first plumbing ───
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const mapRef = React.useRef<ProviderMapHandle>(null);
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
const showMobileMapLayout = isMobile && viewMode === 'map';
// ─── Price input local state (commits on blur / Enter) ───
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
@@ -294,6 +398,257 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onFilterChange({ ...filterValues, funeralTypes: next });
};
// ─── Shared JSX fragments (used by desktop + mobile-map layouts) ───────────
/** The full filter-dialog content — used by both desktop's sticky FilterPanel
* and the mobile-map floating FilterPanel. */
const filterDialogChildren = (
<>
{/* ── Service tradition ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Service tradition
</Typography>
<Autocomplete
value={filterValues.tradition}
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
options={traditionOptions}
renderInput={(params) => (
<TextField {...params} placeholder="Search traditions..." size="small" />
)}
clearOnEscape
size="small"
/>
</Box>
<Divider />
{/* ── Funeral type ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Funeral type
</Typography>
<Box sx={chipWrapSx}>
{funeralTypeOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
selected={filterValues.funeralTypes.includes(option.value)}
onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined"
size="medium"
/>
))}
</Box>
</Box>
<Divider />
{/* ── Provider features ── Switch aligned to the first text line so
wrapped labels read cleanly on narrow screens */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={filterValues.verifiedOnly}
onChange={(_, checked) => onFilterChange({ ...filterValues, verifiedOnly: checked })}
/>
}
label="Verified providers only"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
/>
<FormControlLabel
control={
<Switch
checked={filterValues.onlineArrangements}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, onlineArrangements: checked })
}
/>
}
label="Online arrangements available"
sx={{
mx: 0,
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': { pt: 0.75 },
}}
/>
</Box>
<Divider />
{/* ── Price range ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Price range
</Typography>
<Box sx={{ px: 2.5, mb: 1 }}>
<Slider
value={filterValues.priceRange}
onChange={(_, newValue) =>
onFilterChange({
...filterValues,
priceRange: newValue as [number, number],
})
}
min={minPrice}
max={maxPrice}
step={100}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
color="primary"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
value={priceMinInput}
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Minimum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" color="text.secondary">
</Typography>
<TextField
size="small"
value={priceMaxInput}
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Maximum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
</Box>
</Box>
</>
);
// ─── Mobile map-first layout ───────────────────────────────────────────────
if (showMobileMapLayout) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
bgcolor: 'background.default',
}}
>
{navigation}
<Box component="main" sx={{ position: 'relative', flex: 1, minHeight: 0 }}>
{/* Full-bleed map */}
<Box sx={{ position: 'absolute', inset: 0, display: 'flex' }}>
<ProviderMap
ref={mapRef}
providers={providers}
onSelectProvider={onSelectProvider}
externalisePopups
onActiveChange={setMapActive}
/>
</Box>
{/* Floating control strip — no container chrome; each control has
its own fill/border so it reads cleanly over any map tile */}
<Box
sx={{
position: 'absolute',
top: 12,
left: 12,
right: 12,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{/* Search input — committed-chip pattern, chrome via controlInputSx */}
<LocationSearchInput
value={searchQuery}
onChange={onSearchChange}
onCommit={onSearch}
aria-label="Search providers by town or suburb"
sx={controlInputSx}
/>
{/* Control row: Filters, Sort by, view toggle.
Each control reads as part of one chip set — shared outline,
radius, fill, and shadow via CONTROL_CHROME. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact trigger on the mobile floating strip */}
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant="compact"
sx={controlButtonSx}
/>
{/* View toggle — right-aligned; same outline/radius/fill/shadow
as Filters + Sort, with brand fill on the selected side. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={[{ ml: 'auto', flexShrink: 0 }, controlToggleSx]}
>
<ToggleButton value="list" aria-label="List view">
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
Map
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
{/* Bottom drawer — slides up when a pin/cluster is active */}
<MapProviderDrawer
active={mapActive}
onClose={() => mapRef.current?.clearActive()}
onSelectProvider={onSelectProvider}
onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)}
/>
</Box>
{/* Sticky help bar — shared HelpBar molecule so this footer stays
identical to WizardLayout's (which we bypass in this branch). */}
<HelpBar />
</Box>
);
}
// ─── Desktop + mobile-list layout ──────────────────────────────────────────
return (
<WizardLayout
variant="list-map"
@@ -306,38 +661,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={sx}
secondaryPanel={
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
{/* Floating view toggle */}
{/* Floating view toggle — same chrome as the sticky-bar controls,
anchored to the map panel's top-left. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
position: 'absolute',
top: 12,
left: 12,
zIndex: 1,
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-md)',
borderRadius: 1,
'& .MuiToggleButton-root': {
px: 1.5,
py: 0.5,
fontSize: '0.75rem',
fontWeight: 500,
gap: 0.5,
border: '1px solid',
borderColor: 'divider',
textTransform: 'none',
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
borderColor: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
sx={[
{ position: 'absolute', top: 12, left: 12, zIndex: 1 },
controlToggleSx,
{ '& .MuiToggleButton-root': { gap: 0.75 } },
]}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
@@ -393,28 +729,15 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
borderColor: 'divider',
}}
>
{/* Location search */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search providers by town or suburb"
{/* Location search — committed location renders as a chip inside
the input. Shared with the mobile-map floating strip via the
LocationSearchInput molecule. */}
<LocationSearchInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
),
}}
sx={{ mb: 1.5 }}
onChange={onSearchChange}
onCommit={onSearch}
aria-label="Search providers by town or suburb"
sx={[controlInputSx, { mb: 1.5 }]}
/>
{/* Control bar — filters + sort */}
@@ -425,216 +748,42 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
gap: 1,
}}
>
{/* Filters */}
<FilterPanel activeCount={activeCount} onClear={handleClear}>
{/* ── Location ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Location
</Typography>
<Autocomplete
multiple
freeSolo
value={searchQuery.trim() ? [searchQuery.trim()] : []}
onChange={(_, newValue) => {
// Take the last entered value as the active search
const last = newValue[newValue.length - 1] ?? '';
onSearchChange(typeof last === 'string' ? last : '');
}}
options={[]}
renderInput={(params) => (
<TextField
{...params}
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
size="small"
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ color: 'text.secondary', fontSize: 18 }}
/>
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
}}
/>
)}
size="small"
/>
</Box>
<Divider />
{/* ── Service tradition ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Service tradition
</Typography>
<Autocomplete
value={filterValues.tradition}
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
options={traditionOptions}
renderInput={(params) => (
<TextField {...params} placeholder="Search traditions..." size="small" />
)}
clearOnEscape
size="small"
/>
</Box>
<Divider />
{/* ── Funeral type ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Funeral type
</Typography>
<Box sx={chipWrapSx}>
{funeralTypeOptions.map((option) => (
<Chip
key={option.value}
label={option.label}
selected={filterValues.funeralTypes.includes(option.value)}
onClick={() => handleFuneralTypeToggle(option.value)}
variant="outlined"
size="small"
/>
))}
</Box>
</Box>
<Divider />
{/* ── Provider features ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Switch
checked={filterValues.verifiedOnly}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, verifiedOnly: checked })
}
/>
}
label="Verified providers only"
sx={{ mx: 0 }}
/>
<FormControlLabel
control={
<Switch
checked={filterValues.onlineArrangements}
onChange={(_, checked) =>
onFilterChange({ ...filterValues, onlineArrangements: checked })
}
/>
}
label="Online arrangements available"
sx={{ mx: 0 }}
/>
</Box>
<Divider />
{/* ── Price range ── */}
<Box>
<Typography variant="labelLg" sx={sectionHeadingSx}>
Price range
</Typography>
<Box sx={{ px: 2.5, mb: 1 }}>
<Slider
value={filterValues.priceRange}
onChange={(_, newValue) =>
onFilterChange({
...filterValues,
priceRange: newValue as [number, number],
})
}
min={minPrice}
max={maxPrice}
step={100}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
color="primary"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
value={priceMinInput}
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Minimum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" color="text.secondary">
</Typography>
<TextField
size="small"
value={priceMaxInput}
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{
inputMode: 'numeric',
'aria-label': 'Maximum price',
style: { padding: '6px 0' },
}}
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
/>
</Box>
</Box>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact menu button, pushed right */}
<Box sx={{ ml: 'auto' }}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
sx={{ textTransform: 'none' }}
>
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Sort'}
</Button>
<Menu
anchorEl={sortAnchor}
open={Boolean(sortAnchor)}
onClose={() => setSortAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === sortBy}
onClick={() => {
onSortChange?.(opt.value);
setSortAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
{/* Sort — compact "Sort by" on mobile (grouped left next to
Filters); verbose "Sort: <label>" on desktop (pushed right). */}
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant={isMobile ? 'compact' : 'verbose'}
sx={controlButtonSx}
/>
</Box>
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
Shares the same CONTROL_CHROME as Filters + Sort. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={[
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
controlToggleSx,
]}
>
<ToggleButton value="list" aria-label="List view">
List
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
Map
</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Results count — below controls */}
@@ -644,7 +793,10 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{ mt: 3, display: 'block' }}
aria-live="polite"
>
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
<Box component="span" sx={{ fontWeight: 600, color: 'text.primary' }}>
{providers.length}
</Box>{' '}
provider{providers.length !== 1 ? 's' : ''} found
</Typography>
</Box>

View File

@@ -1,206 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT2 } from './UnverifiedPackageT2';
import type {
UnverifiedPackageT2Data,
UnverifiedPackageT2Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT2';
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 mockProvider: UnverifiedPackageT2Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const mockPackages: UnverifiedPackageT2Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'A funeral service at a chapel or church with a funeral procession, including commonly selected options.',
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: 4900,
description: 'A comprehensive package with premium inclusions and expanded service options.',
},
{
id: 'catholic',
name: 'Catholic Service',
price: 3200,
description:
'Tailored for Catholic funeral traditions including a Requiem Mass and graveside prayers.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT2> = {
title: 'Pages/UnverifiedPackageT2',
component: UnverifiedPackageT2,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT2>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Select a package to see the "Itemised Pricing Unavailable" detail panel */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package selected — detail panel shows price + unavailable notice */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No nearby packages ────────────────────────────────────────────────────
/** Only this provider's packages — no nearby verified section */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -1,318 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT2Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data — price only, no itemised breakdown */
export interface UnverifiedPackageT2Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT2 page component */
export interface UnverifiedPackageT2Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT2Provider;
/** Packages with price only (no itemised breakdown) */
packages: UnverifiedPackageT2Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make an enquiry" is clicked */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the enquiry action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT2 — Package selection page for Tier 2 unverified providers.
*
* Similar to T3 but the provider has only shared overall package prices,
* not itemised breakdowns. The detail panel shows an "Itemized Pricing
* Unavailable" notice instead of line items.
*
* Two sections:
* - **This provider's packages**: price-only, no breakdown available
* - **Similar packages from verified providers nearby**: promoted alternatives
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT2: React.FC<UnverifiedPackageT2Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={[]}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
itemizedUnavailable
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see more details.
</Typography>
</Box>
)
}
>
{/* Provider compact card — no image for unverified */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT2.displayName = 'UnverifiedPackageT2';
export default UnverifiedPackageT2;

View File

@@ -1,2 +0,0 @@
export { default } from './UnverifiedPackageT2';
export * from './UnverifiedPackageT2';

View File

@@ -1,249 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT3 } from './UnverifiedPackageT3';
import type {
UnverifiedPackageT3Data,
UnverifiedPackageT3Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT3';
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 mockProvider: UnverifiedPackageT3Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const matchedPackages: UnverifiedPackageT3Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Accommodation', price: 500 },
{ name: 'Death registration certificate', price: 150 },
{ name: 'Doctor fee for Cremation', price: 150 },
{ name: 'NSW Government Levy - Cremation', price: 83 },
{ name: 'Professional Mortuary Care', price: 1200 },
{ name: 'Professional Service Fee', price: 1120 },
],
},
{
heading: 'Complimentary Items',
items: [
{ name: 'Dressing Fee', price: 0 },
{ name: 'Viewing Fee', price: 0 },
],
},
],
total: 2700,
extras: {
heading: 'Extras',
items: [
{ name: 'Allowance for Flowers', price: 150, isAllowance: true },
{ name: 'Allowance for Master of Ceremonies', price: 500, isAllowance: true },
{ name: 'After Business Hours Service Surcharge', price: 150 },
{ name: 'After Hours Prayers', price: 1920 },
{ name: 'Coffin Bearing by Funeral Directors', price: 1500 },
{ name: 'Digital Recording', price: 500 },
],
},
terms:
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT3> = {
title: 'Pages/UnverifiedPackageT3',
component: UnverifiedPackageT3,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT3>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package already selected — detail panel visible */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
/** No nearby verified packages — only this provider's packages */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -1,333 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT3Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data for the selection list */
export interface UnverifiedPackageT3Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
total?: number;
/** Extra items section (after total) */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT3 page component */
export interface UnverifiedPackageT3Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT3Provider;
/** Packages matching the user's filters from the previous step */
packages: UnverifiedPackageT3Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked (opens provider profile popup) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the arrange action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT3 — Package selection page for unverified (Tier 3) providers.
*
* List + Detail split layout. Left panel shows the selected provider
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
*
* Two sections:
* - **This provider's packages**: estimated pricing from publicly available info
* - **Similar packages from verified providers nearby**: promoted alternatives
* with verified pricing, ratings, and location
*
* Selecting a package reveals its detail. Clicking "Make an enquiry"
* on the detail panel initiates contact with the unverified provider.
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT3: React.FC<UnverifiedPackageT3Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see what&apos;s included.
</Typography>
</Box>
)
}
>
{/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT3.displayName = 'UnverifiedPackageT3';
export default UnverifiedPackageT3;

View File

@@ -1,2 +0,0 @@
export { default } from './UnverifiedPackageT3';
export * from './UnverifiedPackageT3';

View File

@@ -2,10 +2,9 @@ import React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Link } from '../../atoms/Link';
import { Typography } from '../../atoms/Typography';
import { HelpBar } from '../../molecules/HelpBar';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -16,7 +15,8 @@ export type WizardLayoutVariant =
| 'list-map'
| 'list-detail'
| 'grid-sidebar'
| 'detail-toggles';
| 'detail-toggles'
| 'bleed';
/** Props for the WizardLayout template */
export interface WizardLayoutProps {
@@ -50,33 +50,6 @@ export interface WizardLayoutProps {
sx?: SxProps<Theme>;
}
// ─── Help bar ────────────────────────────────────────────────────────────────
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
<Box
component="footer"
sx={{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
);
// ─── Back link ───────────────────────────────────────────────────────────────
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
@@ -362,6 +335,30 @@ const DetailTogglesLayout: React.FC<{
</Box>
);
/** Bleed: full-width scroll host. Main becomes the single scroll container
* (both axes). No inner Container — children are full-bleed. Back link is
* passed into children so it scrolls with the page content. Used by pages
* that own their own width + alignment logic (e.g. ComparisonPage). */
const BleedLayout: React.FC<{
children: React.ReactNode;
backLink?: React.ReactNode;
}> = ({ children, backLink }) => (
<Box
id="wizard-scroll"
data-wizard-scroll
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{backLink}
{children}
</Box>
);
// ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record<
@@ -378,6 +375,7 @@ const LAYOUT_MAP: Record<
'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout,
bleed: BleedLayout,
};
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
@@ -387,12 +385,15 @@ const LAYOUT_MAP: Record<
/**
* Page-level layout template for the FA arrangement wizard.
*
* Provides 5 layout variants matching the wizard page templates:
* Provides 6 layout variants matching the wizard page templates:
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
* - **wide-form**: Wider single column for card grids (coffins, etc.)
* - **list-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details)
* - **bleed**: Viewport-locked, full-width scroll host with no inner container —
* the page owns its own alignment (comparison page)
*
* All variants share: navigation slot, optional back link, sticky help bar,
* and optional progress stepper + running total bar (shown when props provided).
@@ -426,8 +427,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
// list-map + detail-toggles: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles') && {
// list-map + detail-toggles + bleed: lock to viewport so panels scroll independently
...((variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') && {
height: '100vh',
overflow: 'hidden',
}),
@@ -445,15 +446,19 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
<StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Back link — inside children for list-map/detail-toggles/bleed (scrolls with content),
above content for other variants */}
{showBackLink &&
variant !== 'list-map' &&
variant !== 'detail-toggles' &&
variant !== 'bleed' && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */}
<Box
@@ -463,7 +468,8 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
<LayoutComponent
secondaryPanel={secondaryPanel}
backLink={
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
showBackLink &&
(variant === 'list-map' || variant === 'detail-toggles' || variant === 'bleed') ? (
<Box sx={{ pt: 1.5 }}>
<BackLink label={backLabel} onClick={onBack} />
</Box>

View File

@@ -0,0 +1,22 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useBasketUrlSync } from '../../shared/state/useBasketUrlSync';
import { ProvidersRoute } from './routes/Providers';
import { PackagesRoute } from './routes/Packages';
import { ComparisonRoute } from './routes/Comparison';
import { AppCompareBar } from './AppCompareBar';
export function App() {
useBasketUrlSync();
return (
<>
<Routes>
<Route path="/" element={<ProvidersRoute />} />
<Route path="/providers/:providerId/packages" element={<PackagesRoute />} />
<Route path="/comparison" element={<ComparisonRoute />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<AppCompareBar />
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { CompareBar, type CompareBarPackage } from '../../../components/molecules/CompareBar';
import { useComparisonBasket } from '../../shared/state/useComparisonBasket';
import { resolveComparisonPackage, parseBasketKey } from '../../shared/fixtures/packages';
const ERROR_TIMEOUT_MS = 2500;
/**
* App-level CompareBar — hovers above every route except `/comparison`
* itself. Reads the basket store, resolves keys to display labels, and
* navigates to the comparison page when the user activates it.
*
* Surfaces transient error feedback (already-added / max-reached) by
* forwarding `lastError` to CompareBar and auto-clearing after a moment.
*/
export function AppCompareBar() {
const navigate = useNavigate();
const location = useLocation();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const lastError = useComparisonBasket((s) => s.lastError);
const clearError = useComparisonBasket((s) => s.clearError);
useEffect(() => {
if (!lastError) return;
const t = setTimeout(clearError, ERROR_TIMEOUT_MS);
return () => clearTimeout(t);
}, [lastError, clearError]);
if (location.pathname.startsWith('/comparison')) return null;
const packages: CompareBarPackage[] = packageKeys
.map((key) => {
const pkg = resolveComparisonPackage(key);
const parsed = parseBasketKey(key);
if (!pkg || !parsed) return null;
return {
id: key,
name: pkg.name,
providerName: pkg.provider.name,
};
})
.filter((p): p is CompareBarPackage => p !== null);
// CompareBar slides in only when packages.length > 0. To surface "already
// added" / "max reached" errors when the bar isn't yet visible (no items),
// we'd need a separate toast. For now: errors only appear once the bar is
// visible — fine for the common dupe case (basket has ≥1).
return (
<CompareBar
packages={packages}
onCompare={() => navigate('/comparison')}
error={lastError ?? undefined}
/>
);
}

View File

@@ -0,0 +1,31 @@
import Box from '@mui/material/Box';
import { Navigation } from '../../../components/organisms/Navigation';
import { assetUrl } from '../../shared/assets';
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src={assetUrl('/brandlogo/logo-full.svg')}
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src={assetUrl('/brandlogo/logo-short.svg')}
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
export const demoNav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '#' },
{ label: 'Contact Us', href: '#' },
{ label: 'Log in', href: '#' },
]}
/>
);

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arrangement Demo — Funeral Arranger</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { BrowserRouter } from 'react-router-dom';
import { theme } from '../../../theme';
import '../../../theme/generated/tokens.css';
import { App } from './App';
// Vite's `base` is `/arrangement/` in production. In dev the root is this app
// folder so base is `/`. import.meta.env.BASE_URL gives us the right value.
const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,73 @@
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import { ComparisonPage } from '../../../../components/pages/ComparisonPage';
import { Typography } from '../../../../components/atoms/Typography';
import { Button } from '../../../../components/atoms/Button';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { resolveComparisonPackage, DEMO_RECOMMENDED_KEY } from '../../../shared/fixtures/packages';
import { demoNav } from '../DemoNav';
export function ComparisonRoute() {
const navigate = useNavigate();
const packageKeys = useComparisonBasket((s) => s.packageKeys);
const remove = useComparisonBasket((s) => s.remove);
// The system-recommended package is shown as an extra column on top of
// the user's basket. Dedupe against the basket so it never renders twice.
const recommendedPackage = resolveComparisonPackage(DEMO_RECOMMENDED_KEY) ?? undefined;
const packages = packageKeys
.filter((key) => key !== DEMO_RECOMMENDED_KEY)
.map((key) => {
const resolved = resolveComparisonPackage(key);
return resolved ? { key, pkg: resolved } : null;
})
.filter(
(x): x is { key: string; pkg: NonNullable<ReturnType<typeof resolveComparisonPackage>> } =>
x !== null,
);
// Empty state only when there's genuinely nothing to show — normally the
// recommended package will always resolve, so this branch is defensive.
if (packages.length === 0 && !recommendedPackage) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{demoNav}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
textAlign: 'center',
}}
>
<Typography variant="h4">Nothing to compare yet</Typography>
<Typography variant="body1" color="text.secondary">
Pick a provider, choose a package, then tap Compare.
</Typography>
<Button onClick={() => navigate('/')}>Browse providers</Button>
</Box>
</Box>
);
}
return (
<ComparisonPage
packages={packages.map((p) => p.pkg)}
recommendedPackage={recommendedPackage}
onArrange={(id) => alert(`Arrange "${id}" — would route to next wizard step.`)}
onRemove={(id) => {
// ComparisonPackage.id is the bare package id; we need the basket's
// compound key. Find it back via the parallel array.
const entry = packages.find((p) => p.pkg.id === id);
if (entry) remove(entry.key);
}}
onBack={() => navigate(-1)}
navigation={demoNav}
/>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { PackagesStep } from '../../../../components/pages/PackagesStep';
import { providersById, toPackagesStepProvider } from '../../../shared/fixtures/providers';
import {
packagesByProvider,
makeBasketKey,
nearbyVerifiedProviders,
} from '../../../shared/fixtures/packages';
import { useComparisonBasket } from '../../../shared/state/useComparisonBasket';
import { demoNav } from '../DemoNav';
export function PackagesRoute() {
const { providerId = '' } = useParams();
const navigate = useNavigate();
const provider = providersById[providerId];
const bundle = packagesByProvider[providerId];
const basket = useComparisonBasket();
const [selectedId, setSelectedId] = useState<string | null>(bundle?.matching[0]?.id ?? null);
if (!provider || !bundle) return <Navigate to="/" replace />;
// Compare CTA on the PackageDetail panel toggles the selection in the
// basket — adds when absent, removes when present. The button's visible
// state (Compare / Added + ✓) reflects `isSelectedInCart` below. The
// floating CompareBar (mounted in App.tsx) handles navigation once the
// user has 2+ packages selected.
const handleCompare = () => {
if (selectedId) basket.toggle(makeBasketKey(provider.id, selectedId));
};
// When the selected package is already in the basket, PackageDetail swaps
// the Compare button into its "In comparison" selected state.
const isSelectedInCart = selectedId ? basket.has(makeBasketKey(provider.id, selectedId)) : false;
// Tier-3 / tier-2 providers show verified-provider MiniCards instead of
// "more from this provider". Exclude the current provider from the
// "similar" list in case we ever add a verified id that collides.
const secondaryList =
provider.tier === 'verified'
? { kind: 'same-provider-more' as const, packages: bundle.other }
: {
kind: 'nearby-verified' as const,
providers: nearbyVerifiedProviders.filter((p) => p.id !== provider.id),
};
const secondaryHasItems =
secondaryList.kind === 'same-provider-more'
? secondaryList.packages.length > 0
: secondaryList.providers.length > 0;
return (
<PackagesStep
provider={toPackagesStepProvider(provider)}
providerTier={provider.tier}
packages={bundle.matching}
secondaryList={secondaryHasItems ? secondaryList : undefined}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() =>
alert(
provider.tier === 'verified'
? 'Make Arrangement — would route to next wizard step.'
: 'Make an enquiry — would open enquiry form.',
)
}
onCompare={handleCompare}
isSelectedPackageInCart={isSelectedInCart}
onNearbyProviderClick={(id) => navigate(`/providers/${id}/packages`)}
onProviderClick={() => alert('Provider profile — not built in this demo slice.')}
onBack={() => navigate('/')}
navigation={demoNav}
/>
);
}

View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ProvidersStep,
EMPTY_FILTER_VALUES,
type ProviderFilterValues,
type ProviderSortBy,
type ListViewMode,
} from '../../../../components/pages/ProvidersStep';
import { ProviderMap } from '../../../../components/organisms/ProviderMap';
import { providers } from '../../../shared/fixtures/providers';
import { demoNav } from '../DemoNav';
export function ProvidersRoute() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
const [sort, setSort] = useState<ProviderSortBy>('recommended');
const [view, setView] = useState<ListViewMode>('list');
const filtered = providers.filter((p) => p.location.toLowerCase().includes(query.toLowerCase()));
return (
<ProvidersStep
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
searchQuery={query}
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => window.history.back()}
navigation={demoNav}
mapPanel={
<ProviderMap
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
/>
}
/>
);
}

17
src/demo/shared/assets.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Resolve a public-asset path against Vite's base URL.
*
* In dev `import.meta.env.BASE_URL === '/'`, so `assetUrl('/images/foo.png')`
* returns `/images/foo.png` unchanged. In production the build sets base to
* `/arrangement/` (or whatever `--mode <slice>` was passed), and the same
* call returns `/arrangement/images/foo.png` so the bundled assets resolve
* correctly under the slice subpath.
*
* Always pass leading-slash paths — they're relative to the publicDir root.
*/
export const assetUrl = (path: string): string => {
const base = import.meta.env.BASE_URL;
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base;
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import type { ProviderData } from '../../../components/pages/ProvidersStep';
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
import { assetUrl } from '../assets';
export interface DemoProvider extends ProviderData {
id: string;
tier: ProviderTier;
}
export const providers: DemoProvider[] = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-wollongong/01.jpg'),
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
distanceKm: 2.3,
coords: { lat: -34.1074, lng: 141.9166 },
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,
tier: 'verified',
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
rating: 4.8,
reviewCount: 23,
startingPrice: 2450,
distanceKm: 5.1,
coords: { lat: -34.487, lng: 150.897 },
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
tier: 'tier3',
rating: 4.2,
reviewCount: 15,
startingPrice: 3400,
distanceKm: 6.8,
coords: { lat: -34.4278, lng: 150.8931 },
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg'),
logoUrl: assetUrl('/images/providers/killick-family-funerals/logo.png'),
rating: 4.9,
reviewCount: 15,
startingPrice: 3100,
distanceKm: 8.4,
coords: { lat: -26.5408, lng: 151.8388 },
},
{
id: 'mackay',
name: 'Mackay Family Funeral Directors',
location: 'Ourimbah, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/mackay-family-garden-estate/01.jpg'),
logoUrl: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
rating: 4.6,
reviewCount: 87,
startingPrice: 2800,
distanceKm: 18.2,
coords: { lat: -33.3644, lng: 151.3728 },
},
{
id: 'mannings',
name: 'Mannings Funerals',
location: 'Bega, NSW',
verified: true,
tier: 'verified',
imageUrl: assetUrl('/images/venues/mannings-chapel/01.jpg'),
logoUrl: assetUrl('/images/providers/mannings-funerals/logo.png'),
rating: 4.7,
reviewCount: 31,
startingPrice: 2600,
distanceKm: 22.0,
coords: { lat: -36.6742, lng: 149.8417 },
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
tier: 'tier2',
rating: 4.9,
reviewCount: 8,
startingPrice: 5200,
distanceKm: 15.0,
coords: { lat: -33.8988, lng: 151.1794 },
},
];
export const providersById: Record<string, DemoProvider> = providers.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<string, DemoProvider>,
);
/**
* Strip demo-only fields so the value matches PackagesStepProvider exactly.
* (PackagesStepProvider is a structural subset of ProviderData — no `id`, no `tier`.)
*/
export function toPackagesStepProvider(p: DemoProvider): PackagesStepProvider {
return {
name: p.name,
location: p.location,
imageUrl: p.imageUrl,
rating: p.rating,
reviewCount: p.reviewCount,
};
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useComparisonBasket } from './useComparisonBasket';
const PARAM = 'compare';
const serialise = (keys: string[]): string => keys.join(',');
const deserialise = (raw: string | null): string[] =>
raw
? raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
/**
* Two-way sync between the basket store and the `?compare=a:b,c:d` search param.
*
* Mount once near the router root. URL is the source of truth on initial load
* (so a shared link restores the basket); after that, store changes write
* through to the URL so the current basket is always shareable.
*
* In-app navigation from a page that carries `?compare=...` to one that
* doesn't (e.g. Back from PackagesStep to the providers map) would drop the
* param — to avoid wiping the store, we re-attach the store's keys to the
* new URL instead of treating the empty URL as a "clear" signal. External
* URL changes that DO carry params still push back into the store (shared
* links, manual edits, browser Back after a store write).
*/
export function useBasketUrlSync(): void {
const [searchParams, setSearchParams] = useSearchParams();
const initialised = useRef(false);
useEffect(() => {
const urlKeys = deserialise(searchParams.get(PARAM));
const storeKeys = useComparisonBasket.getState().packageKeys;
if (!initialised.current) {
initialised.current = true;
if (urlKeys.length > 0 && serialise(urlKeys) !== serialise(storeKeys)) {
useComparisonBasket.getState().setAll(urlKeys);
}
return;
}
if (serialise(urlKeys) === serialise(storeKeys)) return;
// URL empty + store has items → in-app navigation dropped the param.
// Re-attach the store's keys so the basket stays sticky across routes
// (and the current URL remains shareable).
if (urlKeys.length === 0 && storeKeys.length > 0) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set(PARAM, serialise(storeKeys));
return next;
},
{ replace: true },
);
return;
}
// Otherwise URL is authoritative (shared link, manual edit, browser Back
// after a store write) — push it into the store.
useComparisonBasket.getState().setAll(urlKeys);
}, [searchParams, setSearchParams]);
useEffect(() => {
return useComparisonBasket.subscribe((state, prev) => {
if (serialise(state.packageKeys) === serialise(prev.packageKeys)) return;
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (state.packageKeys.length === 0) next.delete(PARAM);
else next.set(PARAM, serialise(state.packageKeys));
return next;
},
{ replace: true },
);
});
}, [setSearchParams]);
}

View File

@@ -0,0 +1,49 @@
import { create } from 'zustand';
import type { BasketKey } from '../fixtures/packages';
// ComparisonPage caps user-selected packages at 3 (recommended is shown as a
// separate column). Keep the basket aligned so we can't add a 4th and have it
// silently dropped at render time.
const MAX_BASKET = 3;
interface BasketState {
packageKeys: BasketKey[];
/** Transient feedback message — set when add() is rejected (dupe/full) */
lastError: string | null;
add: (key: BasketKey) => void;
remove: (key: BasketKey) => void;
toggle: (key: BasketKey) => void;
clear: () => void;
clearError: () => void;
setAll: (keys: BasketKey[]) => void;
has: (key: BasketKey) => boolean;
isFull: () => boolean;
}
export const useComparisonBasket = create<BasketState>((set, get) => ({
packageKeys: [],
lastError: null,
add: (key) =>
set((state) => {
if (state.packageKeys.includes(key)) {
return { ...state, lastError: 'Already added' };
}
if (state.packageKeys.length >= MAX_BASKET) {
return { ...state, lastError: `Maximum ${MAX_BASKET} packages` };
}
return { packageKeys: [...state.packageKeys, key], lastError: null };
}),
remove: (key) => set((state) => ({ packageKeys: state.packageKeys.filter((k) => k !== key) })),
toggle: (key) => {
const { has, add, remove } = get();
if (has(key)) remove(key);
else add(key);
},
clear: () => set({ packageKeys: [], lastError: null }),
clearError: () => set({ lastError: null }),
setAll: (keys) => set({ packageKeys: keys.slice(0, MAX_BASKET), lastError: null }),
has: (key) => get().packageKeys.includes(key),
isFull: () => get().packageKeys.length >= MAX_BASKET,
}));
export const BASKET_MAX = MAX_BASKET;

50
vite.demo.config.ts Normal file
View File

@@ -0,0 +1,50 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
/**
* Per-slice demo build. Slice name comes from `--mode <name>` and selects
* the app folder, base path, and output directory.
*
* Dev: vite -c vite.demo.config.ts --mode arrangement
* Build: vite build -c vite.demo.config.ts --mode arrangement
* → dist-demo/arrangement/
*/
export default defineConfig(({ mode, command }) => {
const slice = mode;
const appRoot = path.resolve(__dirname, `src/demo/apps/${slice}`);
return {
root: appRoot,
// Load `.env` / `.env.local` from the repo root. Vite's default is to
// read env files from `root`, which here points into `src/demo/apps/...`
// where no env files live — so without this VITE_GOOGLE_MAPS_API_KEY
// never reaches the built bundle and ProviderMap silently falls back
// to its "no API key" empty state in production.
envDir: __dirname,
// Dev server uses absolute base so HMR/asset URLs work at the root;
// production build prefixes assets with /<slice>/ so the bundle is
// portable to any nginx location matching that path.
base: command === 'build' ? `/${slice}/` : '/',
// Mirror Storybook's staticDirs so /brandlogo/, /images/, etc. resolve.
publicDir: path.resolve(__dirname, 'brandassets'),
plugins: [react()],
resolve: {
alias: {
'@atoms': path.resolve(__dirname, 'src/components/atoms'),
'@molecules': path.resolve(__dirname, 'src/components/molecules'),
'@organisms': path.resolve(__dirname, 'src/components/organisms'),
'@theme': path.resolve(__dirname, 'src/theme'),
},
},
build: {
outDir: path.resolve(__dirname, `dist-demo/${slice}`),
emptyOutDir: true,
sourcemap: true,
},
server: {
port: 5180,
open: false,
},
};
});