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>
This commit is contained in:
BIN
brandassets/images/placeholder/hparsonsvenue.jpg
Normal file
BIN
brandassets/images/placeholder/hparsonsvenue.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -57,6 +57,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| ComparisonPackageCard | done | Card + Button + Divider + Typography + Tooltip + LocationOnOutlinedIcon + VerifiedOutlinedIcon + StarRoundedIcon + CheckCircleOutlineIcon + InfoOutlinedIcon | Mobile full-width package card for ComparisonPage tabpanels. Provider header (inline VerifiedOutlinedIcon left of name when verified, name, location + rating, divider, package name, price block, full-width CTA) + itemised sections with left-accent headings. **Warm tint confined to header only** (not Card body) — Card is white (`background.paper`), header has `surface-warm` (recommended) or `surface-subtle` (verified) bg. **2px brand-600 border** when recommended (matches desktop ComparisonColumnCard). Header `px: 3, pt: 3, pb: 4`. Package-info subgroup (name/label/price) in tight nested flex columns. Generous section spacing (`mb: 5` between sections, `py: 2` per item). Recommended banner at top. Shadow (shadow-sm). Medium full-width button. Reuses `ComparisonPackage` type from ComparisonTable. Shared by ComparisonPage V2 and V1 (extracted 2026-04-09). |
|
| ComparisonPackageCard | done | Card + Button + Divider + Typography + Tooltip + LocationOnOutlinedIcon + VerifiedOutlinedIcon + StarRoundedIcon + CheckCircleOutlineIcon + InfoOutlinedIcon | Mobile full-width package card for ComparisonPage tabpanels. Provider header (inline VerifiedOutlinedIcon left of name when verified, name, location + rating, divider, package name, price block, full-width CTA) + itemised sections with left-accent headings. **Warm tint confined to header only** (not Card body) — Card is white (`background.paper`), header has `surface-warm` (recommended) or `surface-subtle` (verified) bg. **2px brand-600 border** when recommended (matches desktop ComparisonColumnCard). Header `px: 3, pt: 3, pb: 4`. Package-info subgroup (name/label/price) in tight nested flex columns. Generous section spacing (`mb: 5` between sections, `py: 2` per item). Recommended banner at top. Shadow (shadow-sm). Medium full-width button. Reuses `ComparisonPackage` type from ComparisonTable. Shared by ComparisonPage V2 and V1 (extracted 2026-04-09). |
|
||||||
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). |
|
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). |
|
||||||
| ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). |
|
| ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). |
|
||||||
|
| NearbyPackageCard | done | Card (outlined, interactive) + Typography + StarRoundedIcon + LocationOnOutlinedIcon | Compact card representing a package offered by a nearby verified provider — package name + price + provider + rating + location. Used in the "Similar packages from verified providers nearby" section of PackagesStep for unverified tiers. Click is a route change to that verified provider's PackagesStep with this package loaded. Extracted from UnverifiedPackageT2/T3 during 2026-04-17 consolidation. |
|
||||||
|
|
||||||
## Organisms
|
## Organisms
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
|-----------|--------|-------------|-------|
|
|-----------|--------|-------------|-------|
|
||||||
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
| IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `<form>` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. |
|
||||||
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. |
|
||||||
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + PackageDetail + Badge + TextField + Typography + Button | Wizard step 3 — package selection. List-detail split: compact provider + budget filter + package list w/ radiogroup (left), PackageDetail breakdown (right). "Most Popular" badge. Mobile Continue button. |
|
| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + NearbyPackageCard + PackageDetail + Divider + Link + Typography | Wizard step 3 — package selection. **Tier-aware unified page** (replaces the old PackagesStep + UnverifiedPackageT2 + UnverifiedPackageT3 trio, 2026-04-17). `providerTier: 'verified' \| 'tier3' \| 'tier2'` drives heading, subhead, `arrangeLabel`, `priceDisclaimer`, and `itemizedUnavailable` via a `TIER_COPY` map. Discriminated `secondaryList`: `same-provider-more` (ServiceOption list, verified) or `nearby-verified` (NearbyPackageCard list, unverified). Same-provider-more **shows top 3 inline**; at >3 shows 3 + `See all N packages from [Provider] →` Link that fires `onSeeAllPackages`. `showAllFromProvider` prop renders a flat "All packages from [Provider]" variant (no grouping, no secondary list, preserves `selectedPackageId`). Primary list suppresses the "Matching your preferences" accent-bar heading when no secondary list is present (so the label only appears when there's something to contrast against). Desktop polished; mobile polish pending. |
|
||||||
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
|
||||||
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
|
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
|
||||||
| DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. |
|
| DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. |
|
||||||
|
|||||||
@@ -26,6 +26,58 @@ Each entry follows this structure:
|
|||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
|
### Session 2026-04-20 — PackageDetail polish + PackagesStep spacing/drill-in + NearbyPackageCard elevation
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.7 (1M context)
|
||||||
|
|
||||||
|
**Context:** Continuation of 2026-04-17b (tier consolidation, still uncommitted). This session focused on finishing the PackagesStep page: PackageDetail header/shadow, price disclaimer visual fix, vertical rhythm in the left column, mobile drill-in navigation, and several copy/content refinements. Preflight clean at end of session.
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
|
||||||
|
**PackageDetail organism:**
|
||||||
|
- Header band background: `surface-warm` → `background.paper` (white). Warm tint was competing with the page header and CTA.
|
||||||
|
- Added `boxShadow: var(--fa-card-shadow-default)` to root for card elevation.
|
||||||
|
- `Compare` button wire-through: added `onCompare` prop to `PackagesStep`, passed through to `PackageDetail` for all three tiers (button only renders when callback provided — was already built into PackageDetail but never exposed).
|
||||||
|
- Price-disclaimer info-box refined: padding 12/8 → 16/12, gap 8 → 10, line-height 1.4 → 1.5, icon alignment fix. Initial attempt with a wrapper `Box` centering the icon on full `1.5em` line-box pushed the icon above optical cap-centre (~2px high). Reverted to the codebase convention `mt: '3px'` directly on the icon (matches `PaymentStep` / `CrematoriumStep`).
|
||||||
|
|
||||||
|
**PackagesStep page:**
|
||||||
|
- Vertical-rhythm pass on the left column. Three user-flagged gaps tuned (multiple iterations — 3 → 4 → 6 → 8 on divider, 3 → 4 → 6 on container mb):
|
||||||
|
- Provider card → h1: `mb: 3` → `mb: 6` (24 → 48px).
|
||||||
|
- Subheading → "Matching your preferences" heading: `mb: 3` → `mb: 6` (24 → 48px).
|
||||||
|
- Primary list → Divider → secondary section: primary list `mb: 3` → `mb: 4`, Divider `mb: 2.5` → `my: 8` (both same-provider-more and nearby-verified dividers). Total gap ≈ 128px + divider line — intentionally larger than 1 & 2 because it separates two distinct groups.
|
||||||
|
- **Mobile drill-in navigation** (< md breakpoint): added `useMediaQuery` + `mobileShowDetail` derived state. On mobile, list and detail render mutually exclusively via `display: { xs: ..., md: 'block' }` toggles. `onSelectPackage` signature widened to `(id: string | null) => void` so the mobile back button can clear selection. When a package is selected on mobile, the WizardLayout's Back button label/action swaps: `"Back" → onBack` becomes `"Back to packages" → onSelectPackage(null)`. `useEffect` scrolls window to top on drill-in so the detail isn't stranded mid-page. Desktop unchanged (both panels always visible).
|
||||||
|
- "See all" link copy changed from `See all {total} packages from {provider.name}` to `See {overflow} more packages from this provider` (overflow = `sameProviderPackages.length - SAME_PROVIDER_INLINE_LIMIT`). Rationale: (a) smaller, action-oriented number ("more" implies gain, not total); (b) "this provider" sidesteps the wrap risk on long provider names; (c) the section heading above ("Other packages from [Provider]") already resolves the ambiguous "this provider" reference. Minor imprecision: clicking lands on "All packages from [Provider]" which shows all packages, not just overflow — acceptable ("user asked for more, got the whole picture").
|
||||||
|
- Verified provider image: placeholder URL → real local asset `/images/placeholder/hparsonsvenue.jpg`. Source was 2048×1366 / 591KB; resized in place to 640×427 / 52KB via ImageMagick (quality 82, metadata stripped). Provider card displays at 120–160px wide so 640 is plenty for 2× retina.
|
||||||
|
|
||||||
|
**NearbyPackageCard molecule:**
|
||||||
|
- Dropped `variant="outlined"` override — now inherits `Card` atom's default `elevated` (shadow). Matches the primary `ServiceOption` cards in the same column. Per design discussion: shadow signals "interactive, lifts on engage" for selectable/linked cards; outlined reserved for context/container cards.
|
||||||
|
|
||||||
|
**ProviderCardCompact:**
|
||||||
|
- Kept outlined (discussed, not changed). Different role: page-level context header, not part of the selection group. Shadow across everything in the column would flatten hierarchy.
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- **Icon alignment convention reaffirmed**: 16px icons with body2 text use `mt: '3px'` directly on the icon with `alignItems: 'flex-start'` on the parent. Don't wrap the icon in a flex-center box — MUI icons have SVG padding that puts geometric centre above optical cap-centre.
|
||||||
|
- **Shadow vs outlined**: interactive content cards (ServiceOption, NearbyPackageCard) use shadow; context/container cards (ProviderCardCompact) use outlined. Avoids flattening visual hierarchy.
|
||||||
|
- **Mobile drill-in over inline-expand or bottom-sheet**: cleanest state model (selection drives view), well-established pattern, no new components.
|
||||||
|
- **"See X more" over "See all X"**: smaller framing, sidesteps long-name wrap.
|
||||||
|
|
||||||
|
**Figma / Make exploration:**
|
||||||
|
- Captured PackagesStep `Tier2` story to the Parsons Figma file (https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT?node-id=6073-25005) for Gemini 3 polish pass in Figma Make.
|
||||||
|
- Drafted two Gemini 3 prompts tailored to Figma Make — one for polish (constraints-last, preserves structure), one for mobile exploration (3 directions: drill-in, inline-expand, bottom-sheet). User chose drill-in for implementation.
|
||||||
|
- Research notes: Gemini 3 weights tail of prompt heavily; prefers TC-EBC framework (Task / Context / Boundaries / Criteria); terse over verbose; declare design-system constraints upfront.
|
||||||
|
|
||||||
|
**Preflight:** TS, ESLint, Prettier (auto-fixed 4 files), Storybook build, token sync, exports all PASS. Hardcoded-hex scan: only pre-existing values in other files; none introduced in this session's edits.
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- None.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- Carry-overs from ComparisonPage 2026-04-17 still open: [P1] mobile tab rail arrow-key nav, [P2] dot indicator tap targets (44px min), [P3] desktop empty state, plus the deferred collapsing sticky header.
|
||||||
|
- MapCard molecule still "planned" in registry — deferred until map integration.
|
||||||
|
- Provider profile page — `onProviderClick` is wired but destination doesn't exist yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Session 2026-04-17 — ComparisonPage restructure (scroll model, sticky-left, tiered hover) + card refinements + mobile polish
|
### Session 2026-04-17 — ComparisonPage restructure (scroll model, sticky-left, tiered hover) + card refinements + mobile polish
|
||||||
|
|
||||||
**Agent(s):** Claude Opus 4.7 (1M context)
|
**Agent(s):** Claude Opus 4.7 (1M context)
|
||||||
@@ -96,9 +148,59 @@ Each entry follows this structure:
|
|||||||
- None blocking.
|
- None blocking.
|
||||||
|
|
||||||
**Next steps:**
|
**Next steps:**
|
||||||
- Commit today's work (2 commits: Phase A+B desktop restructure, then card refinements + mobile polish).
|
- Committed in 2 commits (f146bb0f restructure + 312a77ae mobile polish). Branch is 10 commits ahead of origin/main.
|
||||||
- Optional: `/audit` on refreshed ComparisonPage + ComparisonTable; `/critique` on the mobile and desktop views.
|
- **User-flagged next focus areas** (the reason this session ended): (1) Package Select page refinements; (2) Map pins (MapPin atom is done, MapPopup molecule is done, **MapCard molecule is still "planned"** in the registry — deferred until map integration).
|
||||||
- User flagged next focus areas (not started this session): package select page refinements; map pins / map cards (MapCard molecule is still "planned" in the registry).
|
- **Audit run at end of session — scored 19/20 (Excellent). Carry-overs**:
|
||||||
|
- **[P1] Mobile tab rail arrow-key navigation** — `role="tablist"` expects Left/Right to move focus between tabs, Home/End to jump first/last. Currently Tab into first tab works, but arrows don't move focus. ~20 lines: keyboard handler on tablist moving `activeTabIdx`.
|
||||||
|
- **[P2] Dot indicator tap targets ~24px** — below WCAG 2.5.5 (44×44). Acceptable as a supplement (tab rail is canonical nav) but worth bumping padding from `p: 1` to `p: 1.5` or wrapping in a larger hit region with visually 8px dot.
|
||||||
|
- **[P3] Empty-state on desktop** — if user removes all packages, desktop renders header + divider + empty table zone. Mobile already guards `allPackages.length > 0`; desktop branch should mirror.
|
||||||
|
- **[P3] Info icon tooltips on mobile** — 14px tap + long-press affordance. User has explicitly accepted as exception; noted for record.
|
||||||
|
- **Deferred from this session (ready to rebuild, machinery stripped cleanly)**: the collapsing sticky mini-header on desktop ComparisonTable. Design: full ComparisonColumnCard row `position: sticky; top: 0`, sentinel + IntersectionObserver detects when full row scrolls out, collapses to a mini row showing provider + price + CTA. User wants to pick this up later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session 2026-04-17b — PackagesStep tier consolidation + NearbyPackageCard + "See all" variant
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.7 (1M context)
|
||||||
|
|
||||||
|
**Context:** User asked to continue work on the package select page. Before touching code, scoped out the consolidation: three separate pages (`PackagesStep`, `UnverifiedPackageT3`, `UnverifiedPackageT2`) were structurally identical modulo a handful of tier-driven differences — provider image, heading copy, secondary-list content, and `PackageDetail` flags. Same layout, triplicated. Plus a legacy `PackageSelectPage` story inside `PackageDetail.stories.tsx` predating the real page components.
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
|
||||||
|
- **One unified `PackagesStep` with `providerTier: 'verified' | 'tier3' | 'tier2'`** rather than per-tier pages. Reasoning: every polish pass would otherwise be triplicated (the Comparison work last session landed 20+ small touches — that's a real tax); differences are all *data/copy*, not *layout*. `PackageDetail` already validated this pattern.
|
||||||
|
- **Copy/CTA/disclaimer/itemized-unavailable all derived from tier** via an internal `TIER_COPY` map. Business defaults live in the page, `PackageDetail` stays prop-driven and tier-agnostic.
|
||||||
|
- **Discriminated `secondaryList` union**: `{ kind: 'same-provider-more', packages }` (verified) vs `{ kind: 'nearby-verified', packages }` (unverified). Rendering branches cleanly from the discriminator; types force callers to pick the right shape.
|
||||||
|
- **Routing model for "See all" + package clicks**: URL-driven — every (provider, package, preferences) triple has its own route. Clicking a nearby-verified package → route change to that provider's PackagesStep. "See all N packages from [Provider]" → route change to the *same* PackagesStep with `showAllFromProvider=true` (preference-filter dropped). Component doesn't own "show all" state; caller hands in the full list and flips the flag.
|
||||||
|
- **`showAllFromProvider` variant**: flat list, title becomes "All packages from [Provider]", no grouping, no secondary list, `selectedPackageId` preserved from the origin view. Subhead "Every package [Provider] offers, including those outside your preferences."
|
||||||
|
- **>3 rule** for same-provider-more: show first 3 + "See all N packages from [Provider] →" Link (MUI Link with `component="button"` + `ArrowForwardIcon`). Below the limit, render all inline with no link. Callback is `onSeeAllPackages`.
|
||||||
|
- **"Matching your preferences" accent-bar heading suppressed when no secondary list follows** — same rule as before; the label only appears when there's a contrasting group below it. Also suppressed in `showAllFromProvider` mode.
|
||||||
|
- **`NearbyPackageCard` extracted as a molecule** rather than kept inline. ~50 lines of bespoke JSX was duplicated across T2 and T3 — now a single molecule with its own stories (`Default`, `WithoutRating`, `Static`, `Stacked`).
|
||||||
|
- **Legacy `PackageSelectPage` story deleted** from `PackageDetail.stories.tsx`. Story was a page-level mock predating the real page components — misleading dead weight. Unused imports/helpers pruned (`useState`, `ServiceOption`, `ProviderCardCompact`, `Chip`, `Typography`, `Button`, `Navigation`, `ArrowBackIcon`, `DEMO_IMAGE`, `packages`, `funeralTypes`, `FALogoNav`).
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
|
||||||
|
- New molecule: `src/components/molecules/NearbyPackageCard/` (tsx + stories + index).
|
||||||
|
- New shared types file: `src/components/pages/PackagesStep/types.ts` — `ProviderTier`, `PackagesStepProvider`, `PackageData`, `NearbyVerifiedPackage`, `SecondaryList` discriminated union. Page re-exports them for callers.
|
||||||
|
- Rewrote `src/components/pages/PackagesStep/PackagesStep.tsx` as the tier-aware unified component. `TIER_COPY` map, `SAME_PROVIDER_INLINE_LIMIT = 3`, `GroupHeading` local helper (accent bar + label, primary/secondary emphasis).
|
||||||
|
- Rewrote `PackagesStep.stories.tsx` — 9 stories: `Verified`, `VerifiedWithManyOtherPackages` (exercises >3 rule), `AllFromProvider` (showAllFromProvider variant), `Tier3`, `Tier2`, `NoSelection`, `VerifiedNoSecondary`, `PrePlanning`, `WithError`.
|
||||||
|
- Deleted `src/components/pages/UnverifiedPackageT2/` and `src/components/pages/UnverifiedPackageT3/` entirely.
|
||||||
|
- Deleted the legacy `PackageSelectPage` story in `PackageDetail.stories.tsx` and pruned orphaned imports/helpers.
|
||||||
|
- Registry: added `NearbyPackageCard` row to Molecules; rewrote `PackagesStep` row to describe the tier-aware shape and the `showAllFromProvider` variant.
|
||||||
|
|
||||||
|
**Non-blocking TODOs noted:**
|
||||||
|
|
||||||
|
- **Provider profile page** (future) — `onProviderClick` is wired up on the provider card but the destination isn't built yet.
|
||||||
|
- **Mobile polish** — desktop only this pass (by user instruction); mobile layout to be reconsidered after the desktop shape settles. Current mobile state is the `list-detail` WizardLayout's stacked fallback; not visually tuned.
|
||||||
|
|
||||||
|
**Preflight:** typecheck clean, lint clean.
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- None blocking.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- Uncommitted. Expect a single commit for the consolidation.
|
||||||
|
- Mobile pass on the unified PackagesStep next.
|
||||||
|
- Provider profile page when it comes up.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { NearbyPackageCard } from './NearbyPackageCard';
|
||||||
|
|
||||||
|
const meta: Meta<typeof NearbyPackageCard> = {
|
||||||
|
title: 'Molecules/NearbyPackageCard',
|
||||||
|
component: NearbyPackageCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ maxWidth: 480, width: '100%' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof NearbyPackageCard>;
|
||||||
|
|
||||||
|
/** Default — full metadata including rating and review count */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
packageName: 'Everyday Cremation',
|
||||||
|
price: 4200,
|
||||||
|
providerName: 'H.Parsons Funerals',
|
||||||
|
location: 'Wentworth',
|
||||||
|
rating: 4.5,
|
||||||
|
reviewCount: 32,
|
||||||
|
onClick: () => alert('Navigate to provider package'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Without rating — provider has no reviews yet */
|
||||||
|
export const WithoutRating: Story = {
|
||||||
|
args: {
|
||||||
|
packageName: 'Simple Farewell',
|
||||||
|
price: 3800,
|
||||||
|
providerName: 'Riverstone Funerals',
|
||||||
|
location: 'Mildura',
|
||||||
|
onClick: () => alert('Navigate to provider package'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Non-interactive — no onClick */
|
||||||
|
export const Static: Story = {
|
||||||
|
args: {
|
||||||
|
packageName: 'Everyday Cremation',
|
||||||
|
price: 4200,
|
||||||
|
providerName: 'H.Parsons Funerals',
|
||||||
|
location: 'Wentworth',
|
||||||
|
rating: 4.5,
|
||||||
|
reviewCount: 32,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stacked — as rendered in the similar-packages list */
|
||||||
|
export const Stacked: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<NearbyPackageCard
|
||||||
|
packageName="Everyday Cremation"
|
||||||
|
price={4200}
|
||||||
|
providerName="H.Parsons Funerals"
|
||||||
|
location="Wentworth"
|
||||||
|
rating={4.5}
|
||||||
|
reviewCount={32}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
<NearbyPackageCard
|
||||||
|
packageName="Traditional Farewell"
|
||||||
|
price={6800}
|
||||||
|
providerName="Mackay Family Funerals"
|
||||||
|
location="Parramatta"
|
||||||
|
rating={4.7}
|
||||||
|
reviewCount={58}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
<NearbyPackageCard
|
||||||
|
packageName="Simple Cremation"
|
||||||
|
price={3500}
|
||||||
|
providerName="Coastal Funerals"
|
||||||
|
location="Cronulla"
|
||||||
|
rating={4.3}
|
||||||
|
reviewCount={14}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
106
src/components/molecules/NearbyPackageCard/NearbyPackageCard.tsx
Normal file
106
src/components/molecules/NearbyPackageCard/NearbyPackageCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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 type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Card } from '../../atoms/Card';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA NearbyPackageCard molecule */
|
||||||
|
export interface NearbyPackageCardProps {
|
||||||
|
/** Package display name */
|
||||||
|
packageName: string;
|
||||||
|
/** Package price in dollars */
|
||||||
|
price: number;
|
||||||
|
/** Provider display name */
|
||||||
|
providerName: string;
|
||||||
|
/** Provider location (suburb, city) */
|
||||||
|
location: string;
|
||||||
|
/** Provider rating (e.g. 4.5). Omit to hide. */
|
||||||
|
rating?: number;
|
||||||
|
/** Number of reviews */
|
||||||
|
reviewCount?: number;
|
||||||
|
/** Click handler — navigates to that provider's PackagesStep with this package loaded */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** MUI sx prop */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact card representing a package offered by a nearby verified provider.
|
||||||
|
*
|
||||||
|
* Surfaced in the "Similar packages from verified providers nearby" section
|
||||||
|
* of the unverified-tier PackagesStep pages. Clicking the card is a route
|
||||||
|
* change to that verified provider's PackagesStep with this package loaded.
|
||||||
|
*
|
||||||
|
* Composes Card + Typography.
|
||||||
|
*/
|
||||||
|
export const NearbyPackageCard = React.forwardRef<HTMLDivElement, NearbyPackageCardProps>(
|
||||||
|
({ packageName, price, providerName, location, rating, reviewCount, onClick, sx }, ref) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={ref}
|
||||||
|
interactive={!!onClick}
|
||||||
|
padding="none"
|
||||||
|
onClick={onClick}
|
||||||
|
sx={[{ p: 'var(--fa-card-padding-compact)' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||||
|
>
|
||||||
|
{/* Package name + price */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 2,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" component="span">
|
||||||
|
{packageName}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="labelLg"
|
||||||
|
component="span"
|
||||||
|
color="primary"
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
${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">
|
||||||
|
{providerName}
|
||||||
|
</Typography>
|
||||||
|
{rating != null && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
·
|
||||||
|
</Typography>
|
||||||
|
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{rating}
|
||||||
|
{reviewCount != null ? ` (${reviewCount})` : ''}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
·
|
||||||
|
</Typography>
|
||||||
|
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{location}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
NearbyPackageCard.displayName = 'NearbyPackageCard';
|
||||||
|
export default NearbyPackageCard;
|
||||||
1
src/components/molecules/NearbyPackageCard/index.ts
Normal file
1
src/components/molecules/NearbyPackageCard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NearbyPackageCard, type NearbyPackageCardProps } from './NearbyPackageCard';
|
||||||
@@ -1,17 +1,6 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { PackageDetail } from './PackageDetail';
|
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 = [
|
const essentials = [
|
||||||
{
|
{
|
||||||
@@ -117,41 +106,6 @@ const extras = {
|
|||||||
const termsText =
|
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.';
|
'* 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> = {
|
const meta: Meta<typeof PackageDetail> = {
|
||||||
title: 'Organisms/PackageDetail',
|
title: 'Organisms/PackageDetail',
|
||||||
component: PackageDetail,
|
component: PackageDetail,
|
||||||
@@ -222,132 +176,3 @@ export const WithoutExtras: Story = {
|
|||||||
onCompare: () => alert('Compare'),
|
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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
|
boxShadow: 'var(--fa-card-shadow-default)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
@@ -149,7 +150,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
{/* Header band — warm bg to separate from content */}
|
{/* Header band — warm bg to separate from content */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-warm)',
|
bgcolor: 'background.paper',
|
||||||
px: { xs: 2, sm: 3 },
|
px: { xs: 2, sm: 3 },
|
||||||
pt: 3,
|
pt: 3,
|
||||||
pb: 2.5,
|
pb: 2.5,
|
||||||
@@ -178,10 +179,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: 1,
|
gap: 1.25,
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
px: 1.5,
|
px: 2,
|
||||||
py: 1,
|
py: 1.5,
|
||||||
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
||||||
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
@@ -189,10 +190,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InfoOutlinedIcon
|
<InfoOutlinedIcon
|
||||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
|
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
|
||||||
{priceDisclaimer}
|
{priceDisclaimer}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/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 Box from '@mui/material/Box';
|
||||||
|
import { PackagesStep } from './PackagesStep';
|
||||||
|
import type { NearbyVerifiedPackage, PackageData, PackagesStepProvider } from './PackagesStep';
|
||||||
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── 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',
|
name: 'H.Parsons Funeral Directors',
|
||||||
location: 'Wentworth, NSW',
|
location: 'Wentworth, NSW',
|
||||||
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
|
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
};
|
};
|
||||||
@@ -147,6 +156,110 @@ 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 today’s 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 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const meta: Meta<typeof PackagesStep> = {
|
const meta: Meta<typeof PackagesStep> = {
|
||||||
@@ -161,45 +274,24 @@ const meta: Meta<typeof PackagesStep> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof PackagesStep>;
|
type Story = StoryObj<typeof PackagesStep>;
|
||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
// ─── Verified ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
/** Verified provider — matching packages + up to 3 other packages from the same provider */
|
||||||
export const Default: Story = {
|
export const Verified: 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 = {
|
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
otherPackages={otherPackages}
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -207,21 +299,127 @@ export const WithSelection: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── No other packages (all match) ─────────────────────────────────────────
|
// ─── Verified — with "See all" link ─────────────────────────────────────────
|
||||||
|
|
||||||
/** All packages match filters — no "Other packages" section */
|
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
|
||||||
export const AllMatching: Story = {
|
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', packages: nearbyVerifiedPackages }}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Make an enquiry')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${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', packages: nearbyVerifiedPackages }}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
onArrange={() => alert('Make an enquiry')}
|
||||||
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${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: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
packages={[...matchedPackages, ...otherPackages]}
|
providerTier="verified"
|
||||||
|
packages={matchedPackages}
|
||||||
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -229,7 +427,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 */
|
/** Pre-planning flow — softer copy */
|
||||||
export const PrePlanning: Story = {
|
export const PrePlanning: Story = {
|
||||||
@@ -238,13 +456,15 @@ export const PrePlanning: Story = {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
otherPackages={otherPackages}
|
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
onArrange={() => alert('Open ArrangementDialog')}
|
onArrange={() => alert('Open ArrangementDialog')}
|
||||||
onProviderClick={() => alert('Open provider profile')}
|
onCompare={() => alert('Open compare view')}
|
||||||
|
onProviderClick={() => alert('Open provider profile (future)')}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
isPrePlanning
|
isPrePlanning
|
||||||
@@ -253,16 +473,15 @@ export const PrePlanning: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Validation error ───────────────────────────────────────────────────────
|
/** Validation error */
|
||||||
|
|
||||||
/** Error shown when no package selected */
|
|
||||||
export const WithError: Story = {
|
export const WithError: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PackagesStep
|
<PackagesStep
|
||||||
provider={mockProvider}
|
provider={verifiedProvider}
|
||||||
|
providerTier="verified"
|
||||||
packages={matchedPackages}
|
packages={matchedPackages}
|
||||||
selectedPackageId={selectedId}
|
selectedPackageId={selectedId}
|
||||||
onSelectPackage={setSelectedId}
|
onSelectPackage={setSelectedId}
|
||||||
|
|||||||
@@ -1,68 +1,117 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
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 type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||||
|
import { NearbyPackageCard } from '../../molecules/NearbyPackageCard';
|
||||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
import { PackageDetail } from '../../organisms/PackageDetail';
|
||||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Divider } from '../../atoms/Divider';
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
|
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
export type {
|
||||||
|
PackageData,
|
||||||
|
PackagesStepProvider,
|
||||||
|
NearbyVerifiedPackage,
|
||||||
|
ProviderTier,
|
||||||
|
SecondaryList,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/** Provider summary for the compact card */
|
// ─── Tier copy map ───────────────────────────────────────────────────────────
|
||||||
export interface PackagesStepProvider {
|
|
||||||
/** Provider name */
|
interface TierCopy {
|
||||||
name: string;
|
heading: string;
|
||||||
/** Location */
|
subheading: (isPrePlanning: boolean) => string;
|
||||||
location: string;
|
arrangeLabel: string;
|
||||||
/** Image URL */
|
priceDisclaimer?: string;
|
||||||
imageUrl?: string;
|
itemizedUnavailable: boolean;
|
||||||
/** Rating */
|
emptyDetailMessage: string;
|
||||||
rating?: number;
|
|
||||||
/** Review count */
|
|
||||||
reviewCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Package data for the selection list */
|
const TIER_COPY: Record<ProviderTier, TierCopy> = {
|
||||||
export interface PackageData {
|
verified: {
|
||||||
/** Unique package ID */
|
heading: 'Choose a funeral package',
|
||||||
id: string;
|
subheading: (isPrePlanning) =>
|
||||||
/** Package display name */
|
isPrePlanning
|
||||||
name: string;
|
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||||
/** Package price in dollars */
|
: 'Each package includes a set of services. You can customise your selections in the next steps.',
|
||||||
price: number;
|
arrangeLabel: 'Make Arrangement',
|
||||||
/** Short description */
|
itemizedUnavailable: false,
|
||||||
description?: string;
|
emptyDetailMessage: "Select a package to see what's included.",
|
||||||
/** Line item sections for the detail panel */
|
},
|
||||||
sections: PackageSection[];
|
tier3: {
|
||||||
/** Total price (may differ from base price with extras) */
|
heading: 'Explore available packages',
|
||||||
total?: number;
|
subheading: (isPrePlanning) =>
|
||||||
/** Extra items section (after total) */
|
isPrePlanning
|
||||||
extras?: PackageSection;
|
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
|
||||||
/** Terms and conditions */
|
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.',
|
||||||
terms?: string;
|
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;
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Props for the PackagesStep page component */
|
|
||||||
export interface PackagesStepProps {
|
export interface PackagesStepProps {
|
||||||
/** Provider summary shown at top of the list panel */
|
/** Provider shown at the top of the list panel */
|
||||||
provider: PackagesStepProvider;
|
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[];
|
packages: PackageData[];
|
||||||
/** Other packages from this provider that didn't match filters (shown in secondary group) */
|
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
|
||||||
otherPackages?: PackageData[];
|
secondaryList?: SecondaryList;
|
||||||
/** Currently selected package ID */
|
/** Currently selected package ID */
|
||||||
selectedPackageId: string | null;
|
selectedPackageId: string | null;
|
||||||
/** Callback when a package is selected */
|
/** Callback when a primary-list package is selected (or cleared via mobile back) */
|
||||||
onSelectPackage: (id: string) => void;
|
onSelectPackage: (id: string | null) => void;
|
||||||
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
|
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
|
||||||
onArrange: () => void;
|
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;
|
||||||
|
/** Callback when a nearby-verified package card is clicked (route change to that provider) */
|
||||||
|
onNearbyPackageClick?: (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;
|
onProviderClick?: () => void;
|
||||||
/** Callback for the Back button */
|
/** Callback for the Back button */
|
||||||
onBack: () => void;
|
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 */
|
/** Validation error */
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Whether the arrange action is loading */
|
/** Whether the arrange action is loading */
|
||||||
@@ -75,23 +124,60 @@ export interface PackagesStepProps {
|
|||||||
sx?: SxProps<Theme>;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── 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
|
* Handles all three provider tiers (verified, tier3, tier2) via the
|
||||||
* (compact) and selectable package cards. Right panel shows the full
|
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
|
||||||
* detail breakdown of the selected package with "Make Arrangement" CTA.
|
* itemised-unavailable state are derived from tier.
|
||||||
*
|
*
|
||||||
* Packages are split into two groups:
|
* Left column layout varies by `secondaryList`:
|
||||||
* - **Matching your preferences**: packages that matched the user's filters
|
* - `same-provider-more` (verified): primary "Matching your preferences"
|
||||||
* from the providers step
|
* list + "Other packages from [Provider]" list. If >3 other packages,
|
||||||
* - **Other packages from [Provider]**: remaining packages outside those
|
* shows top 3 + "See all N packages from [Provider] →" link that routes
|
||||||
* filters, shown below a divider for passive discovery
|
* to the same page with `showAllFromProvider`.
|
||||||
|
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
|
||||||
|
* from verified providers nearby" list (NearbyPackageCard).
|
||||||
*
|
*
|
||||||
* Selecting a package reveals its detail. Clicking "Make Arrangement"
|
* When `showAllFromProvider` is true, renders a flat "All packages from
|
||||||
* on the detail panel triggers the ArrangementDialog (D-E).
|
* [Provider]" list with no grouping and no secondary list. The caller
|
||||||
|
* preserves `selectedPackageId` across this navigation.
|
||||||
*
|
*
|
||||||
* Pure presentation component — props in, callbacks out.
|
* Pure presentation component — props in, callbacks out.
|
||||||
*
|
*
|
||||||
@@ -99,37 +185,79 @@ export interface PackagesStepProps {
|
|||||||
*/
|
*/
|
||||||
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||||
provider,
|
provider,
|
||||||
|
providerTier,
|
||||||
packages,
|
packages,
|
||||||
otherPackages = [],
|
secondaryList,
|
||||||
selectedPackageId,
|
selectedPackageId,
|
||||||
onSelectPackage,
|
onSelectPackage,
|
||||||
onArrange,
|
onArrange,
|
||||||
|
onCompare,
|
||||||
|
onNearbyPackageClick,
|
||||||
|
onSeeAllPackages,
|
||||||
onProviderClick,
|
onProviderClick,
|
||||||
onBack,
|
onBack,
|
||||||
|
showAllFromProvider = false,
|
||||||
error,
|
error,
|
||||||
loading = false,
|
loading = false,
|
||||||
navigation,
|
navigation,
|
||||||
isPrePlanning = false,
|
isPrePlanning = false,
|
||||||
sx,
|
sx,
|
||||||
}) => {
|
}) => {
|
||||||
const allPackages = [...packages, ...otherPackages];
|
const copy = TIER_COPY[providerTier];
|
||||||
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
|
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
||||||
const hasOtherPackages = otherPackages.length > 0;
|
|
||||||
|
|
||||||
const subheading = isPrePlanning
|
// Mobile drill-in: when a package is selected on mobile, swap the list view
|
||||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
// for the detail view. Back button clears selection to return to the list.
|
||||||
: 'Each package includes a set of services. You can customise your selections in the next steps.';
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const mobileShowDetail = isMobile && selectedPackageId != null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
|
}, [mobileShowDetail]);
|
||||||
|
|
||||||
|
const handleLayoutBack = mobileShowDetail ? () => onSelectPackage(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 (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="list-detail"
|
variant="list-detail"
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
showBackLink
|
showBackLink
|
||||||
backLabel="Back"
|
backLabel={layoutBackLabel}
|
||||||
onBack={onBack}
|
onBack={handleLayoutBack}
|
||||||
sx={sx}
|
sx={sx}
|
||||||
secondaryPanel={
|
secondaryPanel={
|
||||||
selectedPackage ? (
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: {
|
||||||
|
xs: mobileShowDetail ? 'block' : 'none',
|
||||||
|
md: 'block',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedPackage ? (
|
||||||
<PackageDetail
|
<PackageDetail
|
||||||
name={selectedPackage.name}
|
name={selectedPackage.name}
|
||||||
price={selectedPackage.price}
|
price={selectedPackage.price}
|
||||||
@@ -138,7 +266,11 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
extras={selectedPackage.extras}
|
extras={selectedPackage.extras}
|
||||||
terms={selectedPackage.terms}
|
terms={selectedPackage.terms}
|
||||||
onArrange={onArrange}
|
onArrange={onArrange}
|
||||||
|
onCompare={onCompare}
|
||||||
arrangeDisabled={loading}
|
arrangeDisabled={loading}
|
||||||
|
arrangeLabel={copy.arrangeLabel}
|
||||||
|
priceDisclaimer={copy.priceDisclaimer}
|
||||||
|
itemizedUnavailable={copy.itemizedUnavailable}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
@@ -154,14 +286,24 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
Select a package to see what's included.
|
{copy.emptyDetailMessage}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Provider compact card — clickable to open provider profile */}
|
{/* List column — hidden on mobile when a package is selected (drill-in) */}
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: {
|
||||||
|
xs: mobileShowDetail ? 'none' : 'block',
|
||||||
|
md: 'block',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Provider compact card */}
|
||||||
|
<Box sx={{ mb: 6 }}>
|
||||||
<ProviderCardCompact
|
<ProviderCardCompact
|
||||||
name={provider.name}
|
name={provider.name}
|
||||||
location={provider.location}
|
location={provider.location}
|
||||||
@@ -172,15 +314,15 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Heading */}
|
{/* Heading + subheading */}
|
||||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
Choose a funeral package
|
{heading}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
|
||||||
{subheading}
|
{subheading}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@@ -191,35 +333,14 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Matching packages ─── */}
|
{/* ─── Primary packages ─── */}
|
||||||
{hasOtherPackages && (
|
{/* Show "Matching your preferences" heading only when a secondary list follows */}
|
||||||
<Box
|
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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
|
<Box
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Funeral packages"
|
aria-label={primaryListAriaLabel}
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
|
||||||
>
|
>
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
<ServiceOption
|
<ServiceOption
|
||||||
@@ -241,37 +362,23 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ─── Other packages (passive discovery) ─── */}
|
{/* ─── Secondary: same-provider-more ─── */}
|
||||||
{hasOtherPackages && (
|
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ my: 8 }} />
|
||||||
<Box
|
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 3,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'text.secondary',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
|
||||||
Other packages from {provider.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box
|
<Box
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={`Other packages from ${provider.name}`}
|
aria-label={`Other packages from ${provider.name}`}
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
mb: sameProviderOverflow ? 2 : 3,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{otherPackages.map((pkg) => (
|
{sameProviderVisible.map((pkg) => (
|
||||||
<ServiceOption
|
<ServiceOption
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
name={pkg.name}
|
name={pkg.name}
|
||||||
@@ -282,8 +389,61 @@ export const PackagesStep: React.FC<PackagesStepProps> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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.packages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 8 }} />
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
{activeSecondaryList.packages.map((pkg) => (
|
||||||
|
<NearbyPackageCard
|
||||||
|
key={pkg.id}
|
||||||
|
packageName={pkg.packageName}
|
||||||
|
price={pkg.price}
|
||||||
|
providerName={pkg.providerName}
|
||||||
|
location={pkg.location}
|
||||||
|
rating={pkg.rating}
|
||||||
|
reviewCount={pkg.reviewCount}
|
||||||
|
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
97
src/components/pages/PackagesStep/types.ts
Normal file
97
src/components/pages/PackagesStep/types.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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 package offered by a nearby verified provider (promoted on unverified pages). */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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`: Similar packages from nearby verified providers,
|
||||||
|
* promoted on unverified-tier pages. Rendered as NearbyPackageCard list.
|
||||||
|
* Clicking a card is a route change to that provider's PackagesStep.
|
||||||
|
*/
|
||||||
|
export type SecondaryList =
|
||||||
|
| {
|
||||||
|
kind: 'same-provider-more';
|
||||||
|
packages: PackageData[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'nearby-verified';
|
||||||
|
packages: NearbyVerifiedPackage[];
|
||||||
|
};
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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">
|
|
||||||
·
|
|
||||||
</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">
|
|
||||||
·
|
|
||||||
</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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './UnverifiedPackageT2';
|
|
||||||
export * from './UnverifiedPackageT2';
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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'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">
|
|
||||||
·
|
|
||||||
</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">
|
|
||||||
·
|
|
||||||
</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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './UnverifiedPackageT3';
|
|
||||||
export * from './UnverifiedPackageT3';
|
|
||||||
Reference in New Issue
Block a user