Compare commits
1 Commits
main
...
db9d1ba603
| Author | SHA1 | Date | |
|---|---|---|---|
| db9d1ba603 |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -8,28 +8,9 @@ tokens/export/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
# AI agent tooling
|
# Claude / Playwright artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
.claude/
|
.claude/
|
||||||
.agent/
|
|
||||||
.mcp.json
|
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
|
||||||
GEMINI.md
|
|
||||||
QUICKSTART.md
|
|
||||||
bootstrap.sh
|
|
||||||
|
|
||||||
# Memory and reference docs (not for sharing)
|
|
||||||
docs/memory/
|
|
||||||
docs/reference/impeccable/
|
|
||||||
docs/reference/vercel/
|
|
||||||
docs/reference/cross-tool-workflow.md
|
|
||||||
docs/reference/how-to-work-with-both-tools.md
|
|
||||||
docs/reference/mcp-setup.md
|
|
||||||
docs/reference/retroactive-review-plan.md
|
|
||||||
|
|
||||||
# Deploy scripts (contain credentials)
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Build logs
|
# Build logs
|
||||||
build-storybook.log
|
build-storybook.log
|
||||||
|
|||||||
154
docs/reference/component-lifecycle.md
Normal file
154
docs/reference/component-lifecycle.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Component Lifecycle
|
||||||
|
|
||||||
|
Every component follows this lifecycle. Skills are run in order — each stage must
|
||||||
|
pass before moving to the next. This prevents ad-hoc back-and-forth tweaking.
|
||||||
|
|
||||||
|
## The Stages
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. BUILD /build-atom, /build-molecule, /build-organism │
|
||||||
|
│ 2. STORIES /write-stories │
|
||||||
|
│ 3. INTERNAL QA /audit → /critique → /harden │
|
||||||
|
│ 4. FIX Fix all P0 and P1 issues from stage 3 │
|
||||||
|
│ 5. POLISH /polish → /typeset → /adapt │
|
||||||
|
│ 6. PRESENT Show to user in Storybook │
|
||||||
|
│ 7. ITERATE User feedback → targeted fixes (1-2 rounds) │
|
||||||
|
│ 8. NORMALIZE /normalize (cross-component consistency) │
|
||||||
|
│ 9. PREFLIGHT /preflight │
|
||||||
|
│ 10. COMMIT git add → commit → push │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to use each skill
|
||||||
|
|
||||||
|
### Stage 1 — BUILD
|
||||||
|
**Skill:** `/build-atom`, `/build-molecule`, `/build-organism`
|
||||||
|
**When:** Starting a new component. The skill handles reading memory files,
|
||||||
|
checking the registry, creating the file structure, and writing the code.
|
||||||
|
**Output:** Component .tsx + stories .tsx + index.ts
|
||||||
|
|
||||||
|
### Stage 2 — STORIES
|
||||||
|
**Skill:** `/write-stories`
|
||||||
|
**When:** If the build skill didn't produce comprehensive stories, or if stories
|
||||||
|
need updating after changes. Stories must cover: default, all variants, all
|
||||||
|
sizes, disabled, loading, error, long content, minimal content.
|
||||||
|
**Output:** Complete story coverage in Storybook
|
||||||
|
|
||||||
|
### Stage 3 — INTERNAL QA (run before showing to user)
|
||||||
|
Three skills, run in this order:
|
||||||
|
|
||||||
|
1. **`/audit`** — Technical quality (a11y, performance, theming, responsive, design).
|
||||||
|
Produces a score out of 20 and P0-P3 issues.
|
||||||
|
2. **`/critique`** — UX design review (hierarchy, emotion, cognitive load, composition).
|
||||||
|
Produces a score out of 40 and priority issues.
|
||||||
|
3. **`/harden`** — Edge cases (error states, empty states, loading, boundaries, disabled).
|
||||||
|
Ensures robustness for real-world data.
|
||||||
|
|
||||||
|
**Exit criteria:** No P0 issues remaining. P1 issues documented.
|
||||||
|
|
||||||
|
### Stage 4 — FIX
|
||||||
|
**No skill — just implementation work.**
|
||||||
|
**When:** Fix all P0 and P1 issues found in stage 3.
|
||||||
|
Then re-run the relevant check (e.g., if the fix was an a11y issue, re-run
|
||||||
|
`/audit` to verify). Don't re-run all three unless the fixes were broad.
|
||||||
|
**Exit criteria:** P0 = 0, P1 = 0 (or documented as intentional with rationale).
|
||||||
|
|
||||||
|
### Stage 5 — POLISH
|
||||||
|
Three skills, run as needed based on the component:
|
||||||
|
|
||||||
|
1. **`/polish`** — Visual alignment, spacing, transitions, copy, micro-details.
|
||||||
|
Run on every component.
|
||||||
|
2. **`/typeset`** — Typography: hierarchy, line length, weight, readability.
|
||||||
|
Run on text-heavy components (cards, forms, detail panels).
|
||||||
|
3. **`/adapt`** — Responsive: touch targets, overflow, mobile spacing.
|
||||||
|
Run on layout components (organisms, cards, navigation).
|
||||||
|
|
||||||
|
**Optional context-specific skills:**
|
||||||
|
- **`/quieter`** — Run on components that handle sensitive moments (pricing,
|
||||||
|
commitment steps, error messaging). Not needed for utility atoms.
|
||||||
|
- **`/clarify`** — Run on components with decision points or complex information
|
||||||
|
(FuneralFinder, ArrangementForm, PricingTable). Not needed for simple atoms.
|
||||||
|
|
||||||
|
### Stage 6 — PRESENT
|
||||||
|
**No skill — show in Storybook.**
|
||||||
|
**When:** All internal QA is done. The component should be in its best state
|
||||||
|
before the user sees it. Present with a brief summary of what it does, key
|
||||||
|
design decisions, and scores from audit/critique.
|
||||||
|
|
||||||
|
### Stage 7 — ITERATE
|
||||||
|
**No skill — targeted fixes from user feedback.**
|
||||||
|
**When:** User reviews in Storybook and gives feedback. This should be 1-2 rounds
|
||||||
|
max because stages 3-5 caught most issues. If feedback requires major changes,
|
||||||
|
go back to stage 1. Minor tweaks stay here.
|
||||||
|
**Exit criteria:** User approves.
|
||||||
|
|
||||||
|
### Stage 8 — NORMALIZE
|
||||||
|
**Skill:** `/normalize`
|
||||||
|
**When:** After user approval, run against the component's tier (e.g., `/normalize atoms`)
|
||||||
|
to check it's consistent with its peers. This catches: token access patterns (D031),
|
||||||
|
transition timing, focus styles, spacing methods, displayName, exports.
|
||||||
|
**Note:** This is a cross-component check, so it's most valuable after several
|
||||||
|
components in a tier are done. Can be batched.
|
||||||
|
|
||||||
|
### Stage 9 — PREFLIGHT
|
||||||
|
**Skill:** `/preflight`
|
||||||
|
**When:** Before committing. Verifies TypeScript, Storybook build, token sync,
|
||||||
|
hardcoded values, exports, ESLint, Prettier.
|
||||||
|
**Exit criteria:** All critical checks pass.
|
||||||
|
|
||||||
|
### Stage 10 — COMMIT
|
||||||
|
**No skill — git workflow.**
|
||||||
|
Stage, commit with descriptive message, push. Husky runs lint-staged automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shorthand for quick reference
|
||||||
|
|
||||||
|
| Stage | Skill(s) | Who triggers | Blocking? |
|
||||||
|
|-------|----------|-------------|-----------|
|
||||||
|
| Build | /build-{tier} | User requests | — |
|
||||||
|
| Stories | /write-stories | Auto in build | — |
|
||||||
|
| Internal QA | /audit → /critique → /harden | Agent (auto) | P0 = blocking |
|
||||||
|
| Fix | — | Agent | Until P0/P1 = 0 |
|
||||||
|
| Polish | /polish + /typeset + /adapt | Agent (auto) | — |
|
||||||
|
| Present | — | Agent → User | — |
|
||||||
|
| Iterate | — | User feedback | 1-2 rounds |
|
||||||
|
| Normalize | /normalize | Agent (batch OK) | — |
|
||||||
|
| Preflight | /preflight | Agent (auto) | Critical = blocking |
|
||||||
|
| Commit | — | Agent | — |
|
||||||
|
|
||||||
|
**"Agent (auto)"** means I should run these proactively without being asked.
|
||||||
|
**"Agent (batch OK)"** means it can be deferred and run across multiple components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which skills are optional vs required?
|
||||||
|
|
||||||
|
| Skill | Required for | Optional for |
|
||||||
|
|-------|-------------|-------------|
|
||||||
|
| /audit | All components | — |
|
||||||
|
| /critique | All molecules + organisms | Simple atoms (Button, Divider) |
|
||||||
|
| /harden | All interactive components | Display-only atoms (Typography, Badge) |
|
||||||
|
| /polish | All components | — |
|
||||||
|
| /typeset | Text-heavy components | Icon-only or structural components |
|
||||||
|
| /adapt | Layout components, organisms | Small inline atoms |
|
||||||
|
| /quieter | Sensitive context components | Utility atoms |
|
||||||
|
| /clarify | Decision-point components | Simple atoms |
|
||||||
|
| /normalize | All (batched by tier) | — |
|
||||||
|
| /preflight | All (before commit) | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For existing components
|
||||||
|
|
||||||
|
Components built before this lifecycle was defined can be retroactively
|
||||||
|
reviewed using a condensed process:
|
||||||
|
|
||||||
|
1. `/normalize {tier}` — Scan the tier for consistency issues
|
||||||
|
2. `/audit {component}` — Score each component
|
||||||
|
3. Fix P0/P1 issues only (don't re-polish what's already working)
|
||||||
|
4. `/preflight` → commit
|
||||||
|
|
||||||
|
This is lighter than the full lifecycle because these components have already
|
||||||
|
been through user review and iteration.
|
||||||
203
docs/reference/funeral-finder-logic.md
Normal file
203
docs/reference/funeral-finder-logic.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# FuneralFinder — Flow Logic Reference
|
||||||
|
|
||||||
|
Technical reference for the FuneralFinder stepped search widget.
|
||||||
|
Use this when modifying the flow, adding steps, or integrating with a backend.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The widget is a **single React component** with internal state. No external state
|
||||||
|
management required. The parent only needs to provide `funeralTypes`, optional
|
||||||
|
`themeOptions`, and an `onSearch` callback.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Header (h2 display + subheading) │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ CompletedRows (stack of answered steps)│
|
||||||
|
│ │
|
||||||
|
│ Active Step (one at a time, Collapse) │
|
||||||
|
│ Step 1 │ Step 2 │ Step 3 │ Step 4 │
|
||||||
|
│ │
|
||||||
|
│ ─── always visible ─────────────────── │
|
||||||
|
│ Location input │
|
||||||
|
│ [Find funeral providers] CTA │
|
||||||
|
│ Free to use · No obligation │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
| State variable | Type | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `intent` | `'arrange' \| 'preplan' \| null` | `null` | Step 1 answer |
|
||||||
|
| `planningFor` | `'myself' \| 'someone-else' \| null` | `null` | Step 2 answer (preplan only) |
|
||||||
|
| `typeSelection` | `string \| null` | `null` | Step 3 answer — funeral type ID or `'all'` |
|
||||||
|
| `servicePref` | `'with-service' \| 'without-service' \| 'either'` | `'either'` | Step 4 answer |
|
||||||
|
| `serviceAnswered` | `boolean` | `false` | Whether step 4 was explicitly answered |
|
||||||
|
| `selectedThemes` | `string[]` | `[]` | Optional theme filter IDs (multi-select) |
|
||||||
|
| `location` | `string` | `''` | Location input value |
|
||||||
|
| `locationError` | `string` | `''` | Validation error for location |
|
||||||
|
| `showIntentPrompt` | `boolean` | `false` | Show nudge when CTA clicked without intent |
|
||||||
|
| `editingStep` | `number \| null` | `null` | Which step is being re-edited (via "Change") |
|
||||||
|
|
||||||
|
## Step Flow
|
||||||
|
|
||||||
|
### Active Step Calculation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const activeStep = (() => {
|
||||||
|
if (editingStep !== null) return editingStep; // User clicked "Change"
|
||||||
|
if (!intent) return 1; // Need intent
|
||||||
|
if (needsPlanningFor && !planningFor) return 2; // Need planning-for (preplan only)
|
||||||
|
if (!typeSelection) return 3; // Need funeral type
|
||||||
|
if (showServiceStep && !serviceAnswered) return 4; // Need service pref
|
||||||
|
return 0; // All complete
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
`activeStep === 0` means all optional steps are answered. Only CompletedRows +
|
||||||
|
location + CTA are visible.
|
||||||
|
|
||||||
|
### Step Details
|
||||||
|
|
||||||
|
| Step | Question | Options | Auto-advances? | Conditional? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | How can we help you today? | Arrange now / Pre-plan | Yes, on click | Always shown |
|
||||||
|
| 2 | Who are you planning for? | Myself / Someone else | Yes, on click | Only when `intent === 'preplan'` |
|
||||||
|
| 3 | What type of funeral? | TypeCards + Explore All + theme chips | Yes, on type card click | Always shown |
|
||||||
|
| 4 | Would you like a service? | With / No / Flexible (chips) | Yes, on chip click | Only when selected type has `hasServiceOption: true` |
|
||||||
|
|
||||||
|
### Auto-advance Mechanic
|
||||||
|
|
||||||
|
Steps 1, 2, and 4 auto-advance because selecting an option sets the state and
|
||||||
|
clears `editingStep`. The `activeStep` recalculation on the next render
|
||||||
|
determines the new step.
|
||||||
|
|
||||||
|
Step 3 also auto-advances when a type card is clicked. Theme preferences within
|
||||||
|
step 3 are optional — they're captured at whatever state they're in when the
|
||||||
|
type card click triggers collapse.
|
||||||
|
|
||||||
|
### Editing (reverting to a previous step)
|
||||||
|
|
||||||
|
Clicking "Change" on a CompletedRow calls `revertTo(stepNumber)`, which sets
|
||||||
|
`editingStep`. This overrides the `activeStep` calculation, reopening that step.
|
||||||
|
When the user makes a new selection, the handler clears `editingStep` and the
|
||||||
|
flow recalculates.
|
||||||
|
|
||||||
|
**Key behaviour:** Editing a step does NOT reset downstream answers. If you
|
||||||
|
change from Cremation to Burial (both have `hasServiceOption`), the service
|
||||||
|
preference carries forward. If you change to a type without `hasServiceOption`
|
||||||
|
(or to "Explore all"), `servicePref` resets to `'either'` and `serviceAnswered`
|
||||||
|
resets to `false`.
|
||||||
|
|
||||||
|
## CTA and Search Logic
|
||||||
|
|
||||||
|
### Minimum Requirements
|
||||||
|
|
||||||
|
The CTA button is **always visible and always enabled** (except during loading).
|
||||||
|
Minimum search requirements: **intent + location (3+ chars)**.
|
||||||
|
|
||||||
|
### Submit Behaviour
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Find funeral providers"
|
||||||
|
│
|
||||||
|
├─ intent is null?
|
||||||
|
│ → Show intent prompt (role="alert"), keep step 1 visible
|
||||||
|
│ → Return (don't search)
|
||||||
|
│
|
||||||
|
├─ location < 3 chars?
|
||||||
|
│ → Show error on location input
|
||||||
|
│ → Return (don't search)
|
||||||
|
│
|
||||||
|
└─ Both present?
|
||||||
|
→ Call onSearch() with smart defaults for missing optional fields
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Defaults
|
||||||
|
|
||||||
|
| Field | If not explicitly answered | Default value |
|
||||||
|
|---|---|---|
|
||||||
|
| `funeralTypeId` | User didn't select a type | `null` (= show all types) |
|
||||||
|
| `servicePreference` | User didn't answer service step | `'either'` (= show all) |
|
||||||
|
| `themes` | User didn't select any themes | `[]` (= no filter) |
|
||||||
|
| `planningFor` | User on preplan path but didn't answer step 2 | `undefined` |
|
||||||
|
|
||||||
|
This means a user can: select intent → type location → click CTA. Everything
|
||||||
|
else defaults to "show all."
|
||||||
|
|
||||||
|
### Search Params Shape
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FuneralSearchParams {
|
||||||
|
intent: 'arrange' | 'preplan';
|
||||||
|
planningFor?: 'myself' | 'someone-else'; // Only on preplan path
|
||||||
|
funeralTypeId: string | null; // null = all types
|
||||||
|
servicePreference: 'with-service' | 'without-service' | 'either';
|
||||||
|
themes: string[]; // May be empty
|
||||||
|
location: string; // Trimmed, 3+ chars
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Logic Map
|
||||||
|
|
||||||
|
```
|
||||||
|
intent === 'preplan'
|
||||||
|
└─ Shows step 2 (planning-for)
|
||||||
|
|
||||||
|
typeSelection !== 'all' && selectedType.hasServiceOption === true
|
||||||
|
└─ Shows step 4 (service preference)
|
||||||
|
|
||||||
|
typeSelection !== null
|
||||||
|
└─ CompletedRow for type shows (with theme summary if any selected)
|
||||||
|
|
||||||
|
serviceAnswered && showServiceStep
|
||||||
|
└─ CompletedRow for service shows
|
||||||
|
|
||||||
|
themeOptions.length > 0
|
||||||
|
└─ Theme chips appear within step 3 (always, not gated by type selection)
|
||||||
|
|
||||||
|
loading === true
|
||||||
|
└─ CTA button shows spinner, button disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props Reference
|
||||||
|
|
||||||
|
| Prop | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `funeralTypes` | `FuneralTypeOption[]` | required | Each has `id`, `label`, optional `description`, `note`, `hasServiceOption` |
|
||||||
|
| `themeOptions` | `ThemeOption[]` | `[]` | Each has `id`, `label`. Shown as optional chips in step 3 |
|
||||||
|
| `onSearch` | `(params: FuneralSearchParams) => void` | — | Called on valid submit |
|
||||||
|
| `loading` | `boolean` | `false` | Shows spinner on CTA, disables button |
|
||||||
|
| `heading` | `string` | `'Find funeral directors near you'` | Main h2 heading |
|
||||||
|
| `subheading` | `string` | `'Tell us a little about...'` | Below heading |
|
||||||
|
| `showExploreAll` | `boolean` | `true` | Show "Explore all options" TypeCard |
|
||||||
|
| `sx` | `SxProps<Theme>` | — | MUI sx override on root card |
|
||||||
|
|
||||||
|
## Sub-components (internal)
|
||||||
|
|
||||||
|
| Component | Purpose | Used in |
|
||||||
|
|---|---|---|
|
||||||
|
| `StepHeading` | Centered bodyLg heading with bottom margin | Steps 1-4 |
|
||||||
|
| `ChoiceCard` | Full-width radio card with label + description | Steps 1, 2 |
|
||||||
|
| `TypeCard` | Compact radio card with label + optional description/note | Step 3 |
|
||||||
|
| `CompletedRow` | Summary row: question + bold answer + "Change" link | All completed steps |
|
||||||
|
|
||||||
|
## Adding a New Step
|
||||||
|
|
||||||
|
1. Add state variable(s) for the new step's answer
|
||||||
|
2. Add a condition in `activeStep` calculation (between existing steps)
|
||||||
|
3. Add a `<Collapse in={activeStep === N}>` block in the render
|
||||||
|
4. Add a `<Collapse>` for the CompletedRow (with appropriate visibility condition)
|
||||||
|
5. Include the new data in `handleSubmit` → `onSearch()` params
|
||||||
|
6. Update `FuneralSearchParams` type
|
||||||
|
|
||||||
|
## Known Limitations (deferred)
|
||||||
|
|
||||||
|
- **No progress indicator** — users can't see how many steps remain
|
||||||
|
- **No roving tabindex** — radiogroups use button elements with `role="radio"` but
|
||||||
|
arrow-key navigation between options is not implemented
|
||||||
|
- **No location autocomplete** — free text input only, validated on length
|
||||||
|
- **CSS vars used directly** — some styling uses `var(--fa-*)` tokens instead of
|
||||||
|
MUI theme paths; works but doesn't support dynamic theme switching
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { MapPin } from './MapPin';
|
|
||||||
|
|
||||||
const meta: Meta<typeof MapPin> = {
|
|
||||||
title: 'Atoms/MapPin',
|
|
||||||
component: MapPin,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
backgrounds: {
|
|
||||||
default: 'map',
|
|
||||||
values: [{ name: 'map', value: '#E5E3DF' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
onClick: { action: 'clicked' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof MapPin>;
|
|
||||||
|
|
||||||
/** Verified provider with name and price — warm brand label */
|
|
||||||
export const VerifiedWithPrice: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unverified provider — neutral grey label */
|
|
||||||
export const UnverifiedWithPrice: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Smith & Sons Funerals',
|
|
||||||
price: 1200,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Active/selected state — inverted colours, slight scale-up */
|
|
||||||
export const Active: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Active unverified */
|
|
||||||
export const ActiveUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Smith & Sons Funerals',
|
|
||||||
price: 1200,
|
|
||||||
verified: false,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name only — no price line */
|
|
||||||
export const NameOnly: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Lady Anne Funerals',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name only, unverified */
|
|
||||||
export const NameOnlyUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Local Funeral Services',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — no name, verified */
|
|
||||||
export const PriceOnly: Story = {
|
|
||||||
args: {
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — unverified */
|
|
||||||
export const PriceOnlyUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
price: 1200,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — active */
|
|
||||||
export const PriceOnlyActive: Story = {
|
|
||||||
args: {
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Custom price label */
|
|
||||||
export const CustomPriceLabel: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Premium Services',
|
|
||||||
priceLabel: 'POA',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Long name — truncated with ellipsis at 180px max */
|
|
||||||
export const LongName: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Botanical Funerals by Ian Allison',
|
|
||||||
price: 1200,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Map simulation — multiple pins on a mock map background */
|
|
||||||
export const MapSimulation: Story = {
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
width: 700,
|
|
||||||
height: 450,
|
|
||||||
bgcolor: '#E5E3DF',
|
|
||||||
borderRadius: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
render: () => (
|
|
||||||
<>
|
|
||||||
{/* Verified providers */}
|
|
||||||
<Box sx={{ position: 'absolute', top: 60, left: 80 }}>
|
|
||||||
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
|
||||||
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
|
||||||
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Unverified providers */}
|
|
||||||
<Box sx={{ position: 'absolute', top: 100, left: 450 }}>
|
|
||||||
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
|
||||||
<MapPin name="Local Provider" onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Name only verified */}
|
|
||||||
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
|
|
||||||
<MapPin name="Kenneallys" verified onClick={() => {}} />
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Props for the FA MapPin atom */
|
|
||||||
export interface MapPinProps {
|
|
||||||
/** Provider or venue name — omit for a price-only pill */
|
|
||||||
name?: string;
|
|
||||||
/** Starting package price in dollars — shown as "From $X" */
|
|
||||||
price?: number;
|
|
||||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
|
||||||
priceLabel?: string;
|
|
||||||
/** Whether this provider/venue is verified (brand colour vs neutral) */
|
|
||||||
verified?: boolean;
|
|
||||||
/** Whether this pin is currently active/selected */
|
|
||||||
active?: boolean;
|
|
||||||
/** Click handler */
|
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
|
||||||
/** MUI sx prop for the root element */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PIN_PX = 'var(--fa-map-pin-padding-x)';
|
|
||||||
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
|
|
||||||
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
|
||||||
const MAX_WIDTH = 180;
|
|
||||||
|
|
||||||
// ─── Colour sets ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const colours = {
|
|
||||||
verified: {
|
|
||||||
bg: 'var(--fa-color-brand-100)',
|
|
||||||
name: 'var(--fa-color-brand-900)',
|
|
||||||
price: 'var(--fa-color-brand-600)',
|
|
||||||
activeBg: 'var(--fa-color-brand-700)',
|
|
||||||
activeName: 'var(--fa-color-white)',
|
|
||||||
activePrice: 'var(--fa-color-brand-200)',
|
|
||||||
nub: 'var(--fa-color-brand-100)',
|
|
||||||
activeNub: 'var(--fa-color-brand-700)',
|
|
||||||
border: 'var(--fa-color-brand-300)',
|
|
||||||
activeBorder: 'var(--fa-color-brand-700)',
|
|
||||||
},
|
|
||||||
unverified: {
|
|
||||||
bg: 'var(--fa-color-neutral-100)',
|
|
||||||
name: 'var(--fa-color-neutral-800)',
|
|
||||||
price: 'var(--fa-color-neutral-500)',
|
|
||||||
activeBg: 'var(--fa-color-neutral-700)',
|
|
||||||
activeName: 'var(--fa-color-white)',
|
|
||||||
activePrice: 'var(--fa-color-neutral-200)',
|
|
||||||
nub: 'var(--fa-color-neutral-100)',
|
|
||||||
activeNub: 'var(--fa-color-neutral-700)',
|
|
||||||
border: 'var(--fa-color-neutral-300)',
|
|
||||||
activeBorder: 'var(--fa-color-neutral-700)',
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map marker pin for the FA design system.
|
|
||||||
*
|
|
||||||
* Two-line label marker showing provider name and starting package
|
|
||||||
* price. Renders as a rounded pill with a downward nub pointing to
|
|
||||||
* the exact map location.
|
|
||||||
*
|
|
||||||
* - **Line 1**: Provider name (bold, truncated)
|
|
||||||
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
|
|
||||||
*
|
|
||||||
* Visual distinction:
|
|
||||||
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
|
||||||
* - **Unverified** providers: neutral grey palette
|
|
||||||
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
|
|
||||||
*
|
|
||||||
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
|
|
||||||
* Pure CSS — no canvas, no SVG dependency.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```tsx
|
|
||||||
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
|
||||||
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
|
|
||||||
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
|
|
||||||
* <MapPin name="H.Parsons" price={900} verified active />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|
||||||
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
|
||||||
const palette = verified ? colours.verified : colours.unverified;
|
|
||||||
const hasPrice = price != null || priceLabel != null;
|
|
||||||
|
|
||||||
const priceText =
|
|
||||||
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick(e as unknown as React.MouseEvent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
display: 'inline-flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'transform 150ms ease-in-out',
|
|
||||||
transform: active ? 'scale(1.08)' : 'scale(1)',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'scale(1.08)',
|
|
||||||
},
|
|
||||||
'&:focus-visible': {
|
|
||||||
outline: 'none',
|
|
||||||
'& > .MapPin-label': {
|
|
||||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
|
||||||
outlineOffset: '2px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Label pill */}
|
|
||||||
<Box
|
|
||||||
className="MapPin-label"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
maxWidth: MAX_WIDTH,
|
|
||||||
py: 0.5,
|
|
||||||
px: PIN_PX,
|
|
||||||
borderRadius: PIN_RADIUS,
|
|
||||||
backgroundColor: active ? palette.activeBg : palette.bg,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: active ? palette.activeBorder : palette.border,
|
|
||||||
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
|
|
||||||
transition:
|
|
||||||
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
{name && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
color: active ? palette.activeName : palette.name,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
maxWidth: '100%',
|
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Price line */}
|
|
||||||
{hasPrice && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
fontSize: !name ? 12 : 11,
|
|
||||||
fontWeight: !name ? 700 : 600,
|
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
color: !name
|
|
||||||
? active
|
|
||||||
? palette.activeName
|
|
||||||
: palette.name
|
|
||||||
: active
|
|
||||||
? palette.activePrice
|
|
||||||
: palette.price,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{priceText}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Nub — downward pointer */}
|
|
||||||
<Box
|
|
||||||
aria-hidden
|
|
||||||
sx={{
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${NUB_SIZE} solid transparent`,
|
|
||||||
borderRight: `${NUB_SIZE} solid transparent`,
|
|
||||||
borderTop: `${NUB_SIZE} solid`,
|
|
||||||
borderTopColor: active ? palette.activeNub : palette.nub,
|
|
||||||
mt: '-1px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
MapPin.displayName = 'MapPin';
|
|
||||||
export default MapPin;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { MapPin, default } from './MapPin';
|
|
||||||
export type { MapPinProps } from './MapPin';
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { CompareBar } from './CompareBar';
|
|
||||||
import type { CompareBarPackage } from './CompareBar';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
|
|
||||||
const samplePackages: CompareBarPackage[] = [
|
|
||||||
{ id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' },
|
|
||||||
{ id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' },
|
|
||||||
{ id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const meta: Meta<typeof CompareBar> = {
|
|
||||||
title: 'Molecules/CompareBar',
|
|
||||||
component: CompareBar,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ minHeight: '100vh', p: 4, bgcolor: 'var(--fa-color-surface-subtle)' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
The compare bar floats at the bottom of the viewport.
|
|
||||||
</Typography>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof CompareBar>;
|
|
||||||
|
|
||||||
// --- Default (2 packages) ---------------------------------------------------
|
|
||||||
|
|
||||||
/** Two packages selected — "2 packages ready to compare" */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
packages: samplePackages.slice(0, 2),
|
|
||||||
onCompare: () => alert('Compare clicked'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Single Package ----------------------------------------------------------
|
|
||||||
|
|
||||||
/** One package — "Add another package to compare", CTA disabled */
|
|
||||||
export const SinglePackage: Story = {
|
|
||||||
args: {
|
|
||||||
packages: samplePackages.slice(0, 1),
|
|
||||||
onCompare: () => alert('Compare clicked'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Three Packages (Maximum) ------------------------------------------------
|
|
||||||
|
|
||||||
/** Maximum 3 packages */
|
|
||||||
export const ThreePackages: Story = {
|
|
||||||
args: {
|
|
||||||
packages: samplePackages,
|
|
||||||
onCompare: () => alert('Compare clicked'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- With Error --------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Error message when user tries to add a 4th package */
|
|
||||||
export const WithError: Story = {
|
|
||||||
args: {
|
|
||||||
packages: samplePackages,
|
|
||||||
onCompare: () => alert('Compare clicked'),
|
|
||||||
error: 'Maximum 3 packages',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Empty (Hidden) ----------------------------------------------------------
|
|
||||||
|
|
||||||
/** No packages — bar is hidden */
|
|
||||||
export const Empty: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [],
|
|
||||||
onCompare: () => {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Interactive Demo --------------------------------------------------------
|
|
||||||
|
|
||||||
/** Interactive demo — add packages and see the bar update */
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: () => {
|
|
||||||
const [selected, setSelected] = useState<CompareBarPackage[]>([]);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
|
|
||||||
const allPackages = [
|
|
||||||
...samplePackages,
|
|
||||||
{ id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleToggle = (pkg: CompareBarPackage) => {
|
|
||||||
const isSelected = selected.some((s) => s.id === pkg.id);
|
|
||||||
if (isSelected) {
|
|
||||||
setSelected(selected.filter((s) => s.id !== pkg.id));
|
|
||||||
setError(undefined);
|
|
||||||
} else {
|
|
||||||
if (selected.length >= 3) {
|
|
||||||
setError('Maximum 3 packages');
|
|
||||||
setTimeout(() => setError(undefined), 3000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelected([...selected, pkg]);
|
|
||||||
setError(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ pb: 12 }}>
|
|
||||||
<Typography variant="h4" sx={{ mb: 3 }}>
|
|
||||||
Select packages to compare
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
||||||
{allPackages.map((pkg) => {
|
|
||||||
const isSelected = selected.some((s) => s.id === pkg.id);
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={pkg.id}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: isSelected ? 'primary.main' : 'divider',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
|
||||||
bgcolor: isSelected ? 'var(--fa-color-surface-warm)' : 'background.paper',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="label">{pkg.name}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{pkg.providerName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? 'outlined' : 'soft'}
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleToggle(pkg)}
|
|
||||||
>
|
|
||||||
{isSelected ? 'Remove' : 'Compare'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<CompareBar
|
|
||||||
packages={selected}
|
|
||||||
onCompare={() => alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Paper from '@mui/material/Paper';
|
|
||||||
import Slide from '@mui/material/Slide';
|
|
||||||
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** A package in the comparison basket */
|
|
||||||
export interface CompareBarPackage {
|
|
||||||
/** Unique package ID */
|
|
||||||
id: string;
|
|
||||||
/** Package display name */
|
|
||||||
name: string;
|
|
||||||
/** Provider name */
|
|
||||||
providerName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for the CompareBar molecule */
|
|
||||||
export interface CompareBarProps {
|
|
||||||
/** Packages currently in the comparison basket (max 3 user-selected) */
|
|
||||||
packages: CompareBarPackage[];
|
|
||||||
/** Called when user clicks "Compare" CTA */
|
|
||||||
onCompare: () => void;
|
|
||||||
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
|
|
||||||
error?: string;
|
|
||||||
/** MUI sx prop for the root wrapper */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floating comparison basket pill for the FA design system.
|
|
||||||
*
|
|
||||||
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
|
|
||||||
* Present on both ProvidersStep and PackagesStep.
|
|
||||||
*
|
|
||||||
* Composes Badge + Button + Typography.
|
|
||||||
*/
|
|
||||||
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
|
|
||||||
({ packages, onCompare, error, sx }, ref) => {
|
|
||||||
const count = packages.length;
|
|
||||||
const visible = count > 0;
|
|
||||||
const canCompare = count >= 2;
|
|
||||||
|
|
||||||
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
|
|
||||||
<Paper
|
|
||||||
ref={ref}
|
|
||||||
elevation={8}
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={`${count} of 3 packages selected for comparison`}
|
|
||||||
sx={[
|
|
||||||
(theme: Theme) => ({
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: theme.spacing(3),
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: theme.zIndex.snackbar,
|
|
||||||
borderRadius: '9999px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
px: 2.5,
|
|
||||||
py: 1.25,
|
|
||||||
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
|
|
||||||
}),
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Fraction badge — 1/3, 2/3, 3/3 */}
|
|
||||||
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
|
|
||||||
{count}/3
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Status text */}
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
role={error ? 'alert' : undefined}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 500,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error || statusText}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Compare CTA */}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
startIcon={<CompareArrowsIcon />}
|
|
||||||
onClick={onCompare}
|
|
||||||
disabled={!canCompare}
|
|
||||||
sx={{ flexShrink: 0, borderRadius: '9999px' }}
|
|
||||||
>
|
|
||||||
Compare
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</Slide>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
CompareBar.displayName = 'CompareBar';
|
|
||||||
export default CompareBar;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { CompareBar, default } from './CompareBar';
|
|
||||||
export type { CompareBarProps, CompareBarPackage } from './CompareBar';
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonColumnCard } from './ComparisonColumnCard';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const verifiedPackage: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const unverifiedPackage: ComparisonPackage = {
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const recommendedPackage: ComparisonPackage = {
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
isRecommended: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const longNamePackage: ComparisonPackage = {
|
|
||||||
id: 'long-name',
|
|
||||||
name: 'Comprehensive Premium Memorial & Cremation Service Package',
|
|
||||||
price: 12500,
|
|
||||||
provider: {
|
|
||||||
name: 'The Very Long Name Funeral Services & Memorial Chapel Pty Ltd',
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 87,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const noRatingPackage: ComparisonPackage = {
|
|
||||||
id: 'no-rating',
|
|
||||||
name: 'Basic Funeral Package',
|
|
||||||
price: 4200,
|
|
||||||
provider: {
|
|
||||||
name: 'New Provider',
|
|
||||||
location: 'Sydney',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonColumnCard> = {
|
|
||||||
title: 'Molecules/ComparisonColumnCard',
|
|
||||||
component: ComparisonColumnCard,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ maxWidth: 280, mx: 'auto', pt: 3 }}>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
args: {
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonColumnCard>;
|
|
||||||
|
|
||||||
/** Verified provider — floating "Verified" badge above card */
|
|
||||||
export const Verified: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: verifiedPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
|
|
||||||
export const Unverified: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: unverifiedPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recommended package — copper banner, warm selected state, no Remove link */
|
|
||||||
export const Recommended: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: recommendedPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Long provider name — truncated with tooltip on hover */
|
|
||||||
export const LongName: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: longNamePackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** No rating — provider without rating/review data */
|
|
||||||
export const NoRating: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: noRatingPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Side-by-side — multiple cards in a row (as used in ComparisonTable) */
|
|
||||||
export const SideBySide: Story = {
|
|
||||||
decorators: [
|
|
||||||
() => (
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, pt: 3 }}>
|
|
||||||
<ComparisonColumnCard
|
|
||||||
pkg={recommendedPackage}
|
|
||||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
|
||||||
/>
|
|
||||||
<ComparisonColumnCard
|
|
||||||
pkg={verifiedPackage}
|
|
||||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
|
||||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
|
||||||
/>
|
|
||||||
<ComparisonColumnCard
|
|
||||||
pkg={unverifiedPackage}
|
|
||||||
onArrange={(id) => alert(`Arrange: ${id}`)}
|
|
||||||
onRemove={(id) => alert(`Remove: ${id}`)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
import { Link } from '../../atoms/Link';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ComparisonColumnCardProps {
|
|
||||||
/** Package data to render — same shape used by ComparisonTable */
|
|
||||||
pkg: ComparisonPackage;
|
|
||||||
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
|
|
||||||
onArrange: (packageId: string) => void;
|
|
||||||
/** Called when the user clicks Remove — hidden when not provided or for recommended packages */
|
|
||||||
onRemove?: (packageId: string) => void;
|
|
||||||
/** MUI sx prop for outer wrapper overrides */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatPrice(amount: number): string {
|
|
||||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desktop column header card for the ComparisonTable.
|
|
||||||
*
|
|
||||||
* Shows provider info (verified badge, name, location, rating), package name,
|
|
||||||
* total price, CTA button, and optional Remove link. The verified badge floats
|
|
||||||
* above the card's top edge. Recommended packages get a copper banner and warm
|
|
||||||
* selected card state.
|
|
||||||
*
|
|
||||||
* Used as the sticky header for each column in the desktop comparison grid.
|
|
||||||
* Mobile comparison uses ComparisonPackageCard instead.
|
|
||||||
*/
|
|
||||||
export const ComparisonColumnCard = React.forwardRef<HTMLDivElement, ComparisonColumnCardProps>(
|
|
||||||
({ pkg, onArrange, onRemove, sx }, ref) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
role="columnheader"
|
|
||||||
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'visible',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Floating verified badge — overlaps card top edge */}
|
|
||||||
{pkg.provider.verified && (
|
|
||||||
<Badge
|
|
||||||
color="brand"
|
|
||||||
variant="soft"
|
|
||||||
size="small"
|
|
||||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -12,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: 1,
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card
|
|
||||||
variant="outlined"
|
|
||||||
selected={pkg.isRecommended}
|
|
||||||
padding="none"
|
|
||||||
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
{pkg.isRecommended && (
|
|
||||||
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
|
||||||
<Typography
|
|
||||||
variant="labelSm"
|
|
||||||
sx={{
|
|
||||||
color: 'var(--fa-color-white)',
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Recommended
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
px: 2.5,
|
|
||||||
py: 2.5,
|
|
||||||
pt: pkg.provider.verified ? 3 : 2.5,
|
|
||||||
gap: 0.5,
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Provider name (truncated with tooltip) */}
|
|
||||||
<Tooltip
|
|
||||||
title={pkg.provider.name}
|
|
||||||
arrow
|
|
||||||
placement="top"
|
|
||||||
disableHoverListener={pkg.provider.name.length < 24}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="label"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pkg.provider.name}
|
|
||||||
</Typography>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.provider.location}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Rating */}
|
|
||||||
{pkg.provider.rating != null && (
|
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<StarRoundedIcon
|
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{pkg.provider.rating}
|
|
||||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ width: '100%', my: 1 }} />
|
|
||||||
|
|
||||||
<Typography variant="h6" component="p">
|
|
||||||
{pkg.name}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
Total package price
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
|
||||||
{formatPrice(pkg.price)}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Spacer pushes CTA to bottom across all cards */}
|
|
||||||
<Box sx={{ flex: 1 }} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
|
||||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
|
||||||
size="medium"
|
|
||||||
onClick={() => onArrange(pkg.id)}
|
|
||||||
sx={{ mt: 1.5, px: 4 }}
|
|
||||||
>
|
|
||||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!pkg.isRecommended && onRemove && (
|
|
||||||
<Link
|
|
||||||
component="button"
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
underline="hover"
|
|
||||||
onClick={() => onRemove(pkg.id)}
|
|
||||||
sx={{ mt: 0.5 }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonColumnCard.displayName = 'ComparisonColumnCard';
|
|
||||||
export default ComparisonColumnCard;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
|
||||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonPackageCard } from './ComparisonPackageCard';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const basePackage: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount — upgrade options available.',
|
|
||||||
value: { type: 'allowance', amount: 1750 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Statutory medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination of arrangements.',
|
|
||||||
value: { type: 'price', amount: 3650.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Transfer of the deceased.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
info: 'Professional video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Professional celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 550 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Additional fee for Saturday services.',
|
|
||||||
value: { type: 'price', amount: 880 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const unverifiedPackage: ComparisonPackage = {
|
|
||||||
...basePackage,
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const recommendedPackage: ComparisonPackage = {
|
|
||||||
...basePackage,
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
isRecommended: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonPackageCard> = {
|
|
||||||
title: 'Molecules/ComparisonPackageCard',
|
|
||||||
component: ComparisonPackageCard,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ maxWidth: 400, mx: 'auto' }}>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
args: {
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonPackageCard>;
|
|
||||||
|
|
||||||
/** Verified provider — default appearance used in ComparisonPage mobile tab panel */
|
|
||||||
export const Verified: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: basePackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unverified provider — "Make Enquiry" CTA + soft button variant, no verified badge */
|
|
||||||
export const Unverified: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: unverifiedPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recommended package — warm banner, selected card state, warm header background */
|
|
||||||
export const Recommended: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: recommendedPackage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Itemisation unavailable — used when a provider hasn't submitted an itemised breakdown */
|
|
||||||
export const ItemizedUnavailable: Story = {
|
|
||||||
args: {
|
|
||||||
pkg: {
|
|
||||||
...unverifiedPackage,
|
|
||||||
itemizedAvailable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import type { ComparisonPackage, ComparisonCellValue } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ComparisonPackageCardProps {
|
|
||||||
/** Package data to render — same shape used by ComparisonTable */
|
|
||||||
pkg: ComparisonPackage;
|
|
||||||
/** Called when the user clicks the CTA (Make Arrangement / Make Enquiry) */
|
|
||||||
onArrange: (packageId: string) => void;
|
|
||||||
/** MUI sx prop for container overrides */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatPrice(amount: number): string {
|
|
||||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CellValue({ value }: { value: ComparisonCellValue }) {
|
|
||||||
switch (value.type) {
|
|
||||||
case 'price':
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
|
|
||||||
{formatPrice(value.amount)}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'allowance':
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
|
|
||||||
{formatPrice(value.amount)}*
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'complimentary':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
||||||
<CheckCircleOutlineIcon
|
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
|
|
||||||
Complimentary
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'included':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
||||||
<CheckCircleOutlineIcon
|
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
|
|
||||||
Included
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'poa':
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontStyle: 'italic', textAlign: 'right' }}
|
|
||||||
>
|
|
||||||
Price On Application
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'unknown':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
Unknown
|
|
||||||
</Typography>
|
|
||||||
<InfoOutlinedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'unavailable':
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
|
|
||||||
>
|
|
||||||
—
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile package card for the ComparisonPage mobile tab panel view.
|
|
||||||
*
|
|
||||||
* Full-width card with provider header (verified badge, name, location, rating,
|
|
||||||
* package name, price, CTA) and the package's itemised sections below. Used as
|
|
||||||
* the content of each mobile tabpanel — one card visible at a time, selected
|
|
||||||
* via the tab rail.
|
|
||||||
*
|
|
||||||
* Shared by ComparisonPage (V2) and ComparisonPageV1 so that card-level tweaks
|
|
||||||
* land in a single file.
|
|
||||||
*/
|
|
||||||
export const ComparisonPackageCard = React.forwardRef<HTMLDivElement, ComparisonPackageCardProps>(
|
|
||||||
({ pkg, onArrange, sx }, ref) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={ref}
|
|
||||||
variant="outlined"
|
|
||||||
selected={pkg.isRecommended}
|
|
||||||
padding="none"
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Recommended banner */}
|
|
||||||
{pkg.isRecommended && (
|
|
||||||
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
|
|
||||||
<Typography
|
|
||||||
variant="labelSm"
|
|
||||||
sx={{
|
|
||||||
color: 'var(--fa-color-white)',
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Recommended
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Provider header */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: pkg.isRecommended
|
|
||||||
? 'var(--fa-color-surface-warm)'
|
|
||||||
: 'var(--fa-color-surface-subtle)',
|
|
||||||
px: 2.5,
|
|
||||||
pt: 2.5,
|
|
||||||
pb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Verified badge */}
|
|
||||||
{pkg.provider.verified && (
|
|
||||||
<Badge
|
|
||||||
color="brand"
|
|
||||||
variant="soft"
|
|
||||||
size="small"
|
|
||||||
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
|
|
||||||
sx={{ mb: 1 }}
|
|
||||||
>
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Provider name */}
|
|
||||||
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
|
||||||
{pkg.provider.name}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Location + Rating */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
||||||
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.provider.location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{pkg.provider.rating != null && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
||||||
<StarRoundedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'var(--fa-color-brand-500)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{pkg.provider.rating}
|
|
||||||
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
|
||||||
|
|
||||||
{/* Package name + price */}
|
|
||||||
<Typography variant="h5" component="p">
|
|
||||||
{pkg.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Total package price
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
|
|
||||||
{formatPrice(pkg.price)}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={pkg.provider.verified ? 'contained' : 'soft'}
|
|
||||||
color={pkg.provider.verified ? 'primary' : 'secondary'}
|
|
||||||
size="medium"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => onArrange(pkg.id)}
|
|
||||||
sx={{ mt: 2 }}
|
|
||||||
>
|
|
||||||
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Sections — with left accent borders on headings */}
|
|
||||||
<Box sx={{ px: 2.5, py: 2.5 }}>
|
|
||||||
{pkg.itemizedAvailable === false ? (
|
|
||||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
Itemised pricing not available for this provider.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
pkg.sections.map((section, sIdx) => (
|
|
||||||
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
|
|
||||||
{/* Section heading with left accent */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderLeft: '3px solid',
|
|
||||||
borderLeftColor: 'var(--fa-color-brand-500)',
|
|
||||||
pl: 1.5,
|
|
||||||
mb: 1.5,
|
|
||||||
mt: sIdx > 0 ? 1 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" component="h3">
|
|
||||||
{section.heading}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<Box
|
|
||||||
key={item.name}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ minWidth: 0, flex: '1 1 50%', maxWidth: '60%' }}>
|
|
||||||
<Typography variant="body2" color="text.secondary" component="span">
|
|
||||||
{item.name}
|
|
||||||
</Typography>
|
|
||||||
{item.info && (
|
|
||||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{'\u00A0'}
|
|
||||||
<Tooltip title={item.info} arrow placement="top">
|
|
||||||
<InfoOutlinedIcon
|
|
||||||
aria-label={`More information about ${item.name}`}
|
|
||||||
sx={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--fa-color-neutral-400)',
|
|
||||||
cursor: 'help',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<CellValue value={item.value} />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonPackageCard.displayName = 'ComparisonPackageCard';
|
|
||||||
export default ComparisonPackageCard;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ComparisonPackageCard, default } from './ComparisonPackageCard';
|
|
||||||
export type { ComparisonPackageCardProps } from './ComparisonPackageCard';
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonTabCard } from './ComparisonTabCard';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const verifiedPkg: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const recommendedPkg: ComparisonPackage = {
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
isRecommended: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const unverifiedPkg: ComparisonPackage = {
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const longNamePkg: ComparisonPackage = {
|
|
||||||
id: 'long-name',
|
|
||||||
name: 'Comprehensive Premium Memorial & Cremation Service',
|
|
||||||
price: 12500,
|
|
||||||
provider: {
|
|
||||||
name: 'The Very Long Name Funeral Services Pty Ltd',
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 87,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonTabCard> = {
|
|
||||||
title: 'Molecules/ComparisonTabCard',
|
|
||||||
component: ComparisonTabCard,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
isActive: false,
|
|
||||||
hasRecommended: false,
|
|
||||||
tabId: 'tab-0',
|
|
||||||
tabPanelId: 'panel-0',
|
|
||||||
onClick: () => alert('Tab clicked'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonTabCard>;
|
|
||||||
|
|
||||||
/** Default inactive tab card */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: { pkg: verifiedPkg },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Active/selected state — elevated shadow */
|
|
||||||
export const Active: Story = {
|
|
||||||
args: { pkg: verifiedPkg, isActive: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recommended — badge + brand glow */
|
|
||||||
export const Recommended: Story = {
|
|
||||||
args: { pkg: recommendedPkg, hasRecommended: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Recommended + active */
|
|
||||||
export const RecommendedActive: Story = {
|
|
||||||
args: { pkg: recommendedPkg, isActive: true, hasRecommended: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Long name — truncated with ellipsis */
|
|
||||||
export const LongName: Story = {
|
|
||||||
args: { pkg: longNamePkg },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rail simulation — multiple cards as they appear in the mobile tab rail */
|
|
||||||
export const Rail: Story = {
|
|
||||||
decorators: [
|
|
||||||
() => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 1.5,
|
|
||||||
overflowX: 'auto',
|
|
||||||
py: 2,
|
|
||||||
px: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ComparisonTabCard
|
|
||||||
pkg={recommendedPkg}
|
|
||||||
isActive={false}
|
|
||||||
hasRecommended
|
|
||||||
tabId="tab-0"
|
|
||||||
tabPanelId="panel-0"
|
|
||||||
onClick={() => alert('Recommended')}
|
|
||||||
/>
|
|
||||||
<ComparisonTabCard
|
|
||||||
pkg={verifiedPkg}
|
|
||||||
isActive
|
|
||||||
hasRecommended
|
|
||||||
tabId="tab-1"
|
|
||||||
tabPanelId="panel-1"
|
|
||||||
onClick={() => alert('Wollongong')}
|
|
||||||
/>
|
|
||||||
<ComparisonTabCard
|
|
||||||
pkg={unverifiedPkg}
|
|
||||||
isActive={false}
|
|
||||||
hasRecommended
|
|
||||||
tabId="tab-2"
|
|
||||||
tabPanelId="panel-2"
|
|
||||||
onClick={() => alert('Inglewood')}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ComparisonTabCardProps {
|
|
||||||
/** Package data to render */
|
|
||||||
pkg: ComparisonPackage;
|
|
||||||
/** Whether this tab is the currently active/selected one */
|
|
||||||
isActive: boolean;
|
|
||||||
/** Whether any package in the rail is recommended — controls spacer for alignment */
|
|
||||||
hasRecommended: boolean;
|
|
||||||
/** ARIA: id for the tab element */
|
|
||||||
tabId: string;
|
|
||||||
/** ARIA: id of the controlled tabpanel */
|
|
||||||
tabPanelId: string;
|
|
||||||
/** Called when the tab card is clicked */
|
|
||||||
onClick: () => void;
|
|
||||||
/** MUI sx prop for outer wrapper */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatPrice(amount: number): string {
|
|
||||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mini tab card for the mobile ComparisonPage tab rail.
|
|
||||||
*
|
|
||||||
* Shows provider name, package name, and price. Recommended packages get a
|
|
||||||
* floating badge (in normal flow with negative margin overlap) and a warm
|
|
||||||
* brand glow. Non-recommended cards get a spacer to keep vertical alignment
|
|
||||||
* when a recommended card is present in the rail.
|
|
||||||
*
|
|
||||||
* The page component owns scroll/centering behaviour — this is purely visual.
|
|
||||||
*/
|
|
||||||
export const ComparisonTabCard = React.forwardRef<HTMLDivElement, ComparisonTabCardProps>(
|
|
||||||
({ pkg, isActive, hasRecommended, tabId, tabPanelId, onClick, sx }, ref) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Recommended badge in normal flow — overlaps card via negative mb */}
|
|
||||||
{pkg.isRecommended ? (
|
|
||||||
<Badge
|
|
||||||
color="brand"
|
|
||||||
variant="soft"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
mb: '-10px',
|
|
||||||
zIndex: 1,
|
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Recommended
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
// Spacer keeps cards aligned when a recommended card is present
|
|
||||||
hasRecommended && <Box sx={{ height: 12 }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
aria-controls={tabPanelId}
|
|
||||||
id={tabId}
|
|
||||||
variant="outlined"
|
|
||||||
selected={isActive}
|
|
||||||
padding="none"
|
|
||||||
onClick={onClick}
|
|
||||||
interactive
|
|
||||||
sx={{
|
|
||||||
width: 210,
|
|
||||||
cursor: 'pointer',
|
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
|
||||||
...(pkg.isRecommended && {
|
|
||||||
borderColor: 'var(--fa-color-brand-500)',
|
|
||||||
boxShadow: '0 0 12px rgba(186, 131, 78, 0.3)',
|
|
||||||
}),
|
|
||||||
...(isActive && {
|
|
||||||
boxShadow: pkg.isRecommended
|
|
||||||
? '0 0 14px rgba(186, 131, 78, 0.4)'
|
|
||||||
: 'var(--fa-shadow-md)',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ px: 2, pt: 2.4, pb: 2 }}>
|
|
||||||
<Typography
|
|
||||||
variant="labelSm"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
display: 'block',
|
|
||||||
mb: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pkg.provider.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pkg.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'primary.main',
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatPrice(pkg.price)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonTabCard.displayName = 'ComparisonTabCard';
|
|
||||||
export default ComparisonTabCard;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
|
||||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { MapPopup } from './MapPopup';
|
|
||||||
import { MapPin } from '../../atoms/MapPin';
|
|
||||||
|
|
||||||
// Placeholder images
|
|
||||||
const IMG_PROVIDER =
|
|
||||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=200&fit=crop&auto=format';
|
|
||||||
const IMG_VENUE =
|
|
||||||
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=200&fit=crop&auto=format';
|
|
||||||
|
|
||||||
const meta: Meta<typeof MapPopup> = {
|
|
||||||
title: 'Molecules/MapPopup',
|
|
||||||
component: MapPopup,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
backgrounds: {
|
|
||||||
default: 'map',
|
|
||||||
values: [{ name: 'map', value: '#E5E3DF' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
onClick: { action: 'clicked' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof MapPopup>;
|
|
||||||
|
|
||||||
/** Verified provider with image, price, location, and rating */
|
|
||||||
export const VerifiedProvider: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
price: 900,
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.8,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unverified provider — no image, no badge */
|
|
||||||
export const UnverifiedProvider: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Smith & Sons Funeral Services',
|
|
||||||
price: 1200,
|
|
||||||
location: 'Sutherland',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Venue popup — capacity instead of rating */
|
|
||||||
export const Venue: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Albany Creek Memorial Park — Garden Chapel',
|
|
||||||
imageUrl: IMG_VENUE,
|
|
||||||
price: 450,
|
|
||||||
location: 'Albany Creek',
|
|
||||||
capacity: 120,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Long name — truncated at 1 line, tooltip on hover */
|
|
||||||
export const LongName: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
price: 1200,
|
|
||||||
location: 'Northern Beaches',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Minimal — just name */
|
|
||||||
export const Minimal: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Local Funeral Provider',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Verified without image — inline verified indicator */
|
|
||||||
export const VerifiedNoImage: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
price: 900,
|
|
||||||
location: 'Wollongong',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Custom price label */
|
|
||||||
export const CustomPriceLabel: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Premium Funeral Services',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
priceLabel: 'Price on application',
|
|
||||||
location: 'Sydney CBD',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Pin + Popup composition — shows how they work together on a map */
|
|
||||||
export const WithPin: Story = {
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
width: 400,
|
|
||||||
height: 380,
|
|
||||||
bgcolor: '#E5E3DF',
|
|
||||||
borderRadius: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
render: () => (
|
|
||||||
<>
|
|
||||||
<MapPopup
|
|
||||||
name="H.Parsons Funeral Directors"
|
|
||||||
imageUrl={IMG_PROVIDER}
|
|
||||||
price={900}
|
|
||||||
location="Wollongong"
|
|
||||||
rating={4.8}
|
|
||||||
verified
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
<MapPin name="H.Parsons" price={900} verified active />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Paper from '@mui/material/Paper';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Props for the FA MapPopup molecule */
|
|
||||||
export interface MapPopupProps {
|
|
||||||
/** Provider/venue name */
|
|
||||||
name: string;
|
|
||||||
/** Hero image URL */
|
|
||||||
imageUrl?: string;
|
|
||||||
/** Price in dollars — shown as "From $X" */
|
|
||||||
price?: number;
|
|
||||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
|
||||||
priceLabel?: string;
|
|
||||||
/** Location text (suburb, city) */
|
|
||||||
location?: string;
|
|
||||||
/** Average rating (e.g. 4.8) */
|
|
||||||
rating?: number;
|
|
||||||
/** Venue capacity */
|
|
||||||
capacity?: number;
|
|
||||||
/** Whether this provider is verified — shows icon badge in image */
|
|
||||||
verified?: boolean;
|
|
||||||
/** Click handler — entire card is clickable */
|
|
||||||
onClick?: () => void;
|
|
||||||
/** MUI sx prop for the root element */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const POPUP_WIDTH = 260;
|
|
||||||
const IMAGE_HEIGHT = 100;
|
|
||||||
const NUB_SIZE = 8;
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map popup card for the FA design system.
|
|
||||||
*
|
|
||||||
* Floating card anchored to a MapPin on click. Shows a compact
|
|
||||||
* preview of a provider or venue — image, name, meta, and price.
|
|
||||||
* The entire card is clickable to navigate to the provider/venue.
|
|
||||||
*
|
|
||||||
* Content hierarchy matches MiniCard: **title → meta → price**.
|
|
||||||
* Truncated names show a tooltip on hover. Verified providers
|
|
||||||
* show an icon-only badge floating in the image.
|
|
||||||
*
|
|
||||||
* Designed for use as a custom popup in Mapbox GL / Google Maps.
|
|
||||||
* The parent map container handles positioning; this component
|
|
||||||
* handles content and styling only.
|
|
||||||
*
|
|
||||||
* Composes: Paper + Typography + Tooltip.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```tsx
|
|
||||||
* <MapPopup
|
|
||||||
* name="H.Parsons Funeral Directors"
|
|
||||||
* imageUrl="/images/parsons.jpg"
|
|
||||||
* price={900}
|
|
||||||
* location="Wollongong"
|
|
||||||
* rating={4.8}
|
|
||||||
* verified
|
|
||||||
* onClick={() => selectProvider(id)}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
imageUrl,
|
|
||||||
price,
|
|
||||||
priceLabel,
|
|
||||||
location,
|
|
||||||
rating,
|
|
||||||
capacity,
|
|
||||||
verified = false,
|
|
||||||
onClick,
|
|
||||||
sx,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const hasMeta = location != null || rating != null || capacity != null;
|
|
||||||
const hasPrice = price != null || priceLabel != null;
|
|
||||||
|
|
||||||
// Detect name truncation for tooltip
|
|
||||||
const nameRef = React.useRef<HTMLElement>(null);
|
|
||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = nameRef.current;
|
|
||||||
if (el) {
|
|
||||||
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
|
|
||||||
}
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
role={onClick ? 'button' : undefined}
|
|
||||||
tabIndex={onClick ? 0 : undefined}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={
|
|
||||||
onClick
|
|
||||||
? (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
aria-label={onClick ? `View ${name}` : undefined}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
display: 'inline-flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
transition: 'transform 150ms ease-in-out',
|
|
||||||
'&:hover': onClick
|
|
||||||
? {
|
|
||||||
transform: 'scale(1.02)',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
'&:focus-visible': {
|
|
||||||
outline: '2px solid var(--fa-color-interactive-focus)',
|
|
||||||
outlineOffset: '2px',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
width: POPUP_WIDTH,
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ── Image ── */}
|
|
||||||
{imageUrl && (
|
|
||||||
<Box
|
|
||||||
role="img"
|
|
||||||
aria-label={`Photo of ${name}`}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
height: IMAGE_HEIGHT,
|
|
||||||
backgroundImage: `url(${imageUrl})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundColor: 'var(--fa-color-neutral-100)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Verified icon badge — floating top-right */}
|
|
||||||
{verified && (
|
|
||||||
<Tooltip title="Verified provider" arrow placement="top">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'var(--fa-color-brand-600)',
|
|
||||||
color: 'var(--fa-color-white)',
|
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Content ── */}
|
|
||||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
||||||
{/* 1. Name — with tooltip when truncated */}
|
|
||||||
<Tooltip
|
|
||||||
title={isTruncated ? name : ''}
|
|
||||||
arrow
|
|
||||||
placement="top"
|
|
||||||
enterDelay={300}
|
|
||||||
disableHoverListener={!isTruncated}
|
|
||||||
>
|
|
||||||
<Typography ref={nameRef} variant="body2" sx={{ fontWeight: 600 }} maxLines={1}>
|
|
||||||
{name}
|
|
||||||
</Typography>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 2. Meta row */}
|
|
||||||
{hasMeta && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
{location && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
|
||||||
{location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rating != null && (
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}
|
|
||||||
aria-label={`Rated ${rating} out of 5`}
|
|
||||||
>
|
|
||||||
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
|
||||||
{rating}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capacity != null && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
|
||||||
<PeopleOutlinedIcon
|
|
||||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
|
||||||
{capacity}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 3. Price */}
|
|
||||||
{hasPrice && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
|
||||||
{priceLabel ? (
|
|
||||||
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
{priceLabel}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
From
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
|
|
||||||
${price!.toLocaleString('en-AU')}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Verified indicator (no-image fallback) */}
|
|
||||||
{verified && !imageUrl && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<VerifiedOutlinedIcon sx={{ fontSize: 14, color: 'var(--fa-color-brand-600)' }} />
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
|
||||||
Verified
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Nub — downward pointer connecting to pin */}
|
|
||||||
<Box
|
|
||||||
aria-hidden
|
|
||||||
sx={{
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${NUB_SIZE}px solid transparent`,
|
|
||||||
borderRight: `${NUB_SIZE}px solid transparent`,
|
|
||||||
borderTop: `${NUB_SIZE}px solid`,
|
|
||||||
borderTopColor: 'background.paper',
|
|
||||||
mt: '-1px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
MapPopup.displayName = 'MapPopup';
|
|
||||||
export default MapPopup;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { MapPopup, default } from './MapPopup';
|
|
||||||
export type { MapPopupProps } from './MapPopup';
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { MiniCard } from './MiniCard';
|
|
||||||
|
|
||||||
// Placeholder images for stories
|
|
||||||
const IMG_PROVIDER =
|
|
||||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format';
|
|
||||||
const IMG_VENUE =
|
|
||||||
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format';
|
|
||||||
const IMG_CHAPEL =
|
|
||||||
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format';
|
|
||||||
const IMG_GARDEN =
|
|
||||||
'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format';
|
|
||||||
|
|
||||||
const meta: Meta<typeof MiniCard> = {
|
|
||||||
title: 'Molecules/MiniCard',
|
|
||||||
component: MiniCard,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ width: 240 }}>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof MiniCard>;
|
|
||||||
|
|
||||||
/** Default — verified provider with image, location, and price */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'H.Parsons Funeral Directors',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
verified: true,
|
|
||||||
price: 900,
|
|
||||||
location: 'Wollongong',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** With all optional fields populated */
|
|
||||||
export const FullyLoaded: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'H.Parsons Funeral Directors',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
verified: true,
|
|
||||||
location: 'Wollongong',
|
|
||||||
rating: 4.8,
|
|
||||||
price: 900,
|
|
||||||
badges: [{ label: 'Online Arrangement', color: 'success' }],
|
|
||||||
chips: ['Burial', 'Cremation'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Unverified provider — no badge in image */
|
|
||||||
export const Unverified: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Smith & Sons Funeral Services',
|
|
||||||
imageUrl: IMG_VENUE,
|
|
||||||
price: 1200,
|
|
||||||
location: 'Sutherland',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Venue card usage — capacity instead of rating */
|
|
||||||
export const Venue: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Albany Creek Memorial Park',
|
|
||||||
imageUrl: IMG_CHAPEL,
|
|
||||||
price: 450,
|
|
||||||
location: 'Albany Creek',
|
|
||||||
capacity: 120,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Package card usage — custom price label */
|
|
||||||
export const Package: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Essential Cremation Package',
|
|
||||||
imageUrl: IMG_GARDEN,
|
|
||||||
priceLabel: 'From $2,800',
|
|
||||||
badges: [{ label: 'Most Popular', color: 'brand' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Minimal — just title and image */
|
|
||||||
export const Minimal: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Lady Anne Funerals',
|
|
||||||
imageUrl: IMG_VENUE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Selected state — brand border + warm background */
|
|
||||||
export const Selected: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'H.Parsons Funeral Directors',
|
|
||||||
imageUrl: IMG_PROVIDER,
|
|
||||||
verified: true,
|
|
||||||
price: 900,
|
|
||||||
location: 'Wollongong',
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Long title — truncated at 2 lines, hover tooltip shows full text */
|
|
||||||
export const LongTitle: Story = {
|
|
||||||
args: {
|
|
||||||
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
|
|
||||||
imageUrl: IMG_GARDEN,
|
|
||||||
verified: true,
|
|
||||||
location: 'Northern Beaches',
|
|
||||||
rating: 4.9,
|
|
||||||
price: 1200,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Multiple cards in a responsive grid — mix of verified and unverified */
|
|
||||||
export const Grid: Story = {
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
||||||
gap: 2,
|
|
||||||
width: 680,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
render: () => (
|
|
||||||
<>
|
|
||||||
<MiniCard
|
|
||||||
title="H.Parsons Funeral Directors"
|
|
||||||
imageUrl={IMG_PROVIDER}
|
|
||||||
verified
|
|
||||||
location="Wollongong"
|
|
||||||
rating={4.8}
|
|
||||||
price={900}
|
|
||||||
chips={['Burial', 'Cremation']}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
<MiniCard
|
|
||||||
title="Albany Creek Memorial Park"
|
|
||||||
imageUrl={IMG_CHAPEL}
|
|
||||||
location="Albany Creek"
|
|
||||||
capacity={120}
|
|
||||||
price={450}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
<MiniCard
|
|
||||||
title="Lady Anne Funerals"
|
|
||||||
imageUrl={IMG_VENUE}
|
|
||||||
location="Sutherland Shire"
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
||||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
|
||||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
|
|
||||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Badge } from '../../atoms/Badge';
|
|
||||||
import type { BadgeProps } from '../../atoms/Badge/Badge';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** A badge to render inside the MiniCard content area */
|
|
||||||
export interface MiniCardBadge {
|
|
||||||
/** Label text */
|
|
||||||
label: string;
|
|
||||||
/** Badge colour intent */
|
|
||||||
color?: BadgeProps['color'];
|
|
||||||
/** Badge variant */
|
|
||||||
variant?: BadgeProps['variant'];
|
|
||||||
/** Optional leading icon */
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for the FA MiniCard molecule */
|
|
||||||
export interface MiniCardProps {
|
|
||||||
/** Card title — provider name, venue name, package name, etc. */
|
|
||||||
title: string;
|
|
||||||
/** Hero image URL */
|
|
||||||
imageUrl: string;
|
|
||||||
/** Alt text for the image — defaults to title */
|
|
||||||
imageAlt?: string;
|
|
||||||
/** Whether this provider/venue is verified — shows icon badge in image */
|
|
||||||
verified?: boolean;
|
|
||||||
/** Price in dollars — shown as "From $X" */
|
|
||||||
price?: number;
|
|
||||||
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
|
|
||||||
priceLabel?: string;
|
|
||||||
/** Location text (suburb, city) */
|
|
||||||
location?: string;
|
|
||||||
/** Average rating (e.g. 4.8) */
|
|
||||||
rating?: number;
|
|
||||||
/** Venue capacity (e.g. 120) */
|
|
||||||
capacity?: number;
|
|
||||||
/** Badge items rendered after the price row */
|
|
||||||
badges?: MiniCardBadge[];
|
|
||||||
/** Chip labels rendered as small soft badges (after badges) */
|
|
||||||
chips?: string[];
|
|
||||||
/** Whether this card is currently selected */
|
|
||||||
selected?: boolean;
|
|
||||||
/** Click handler — entire card is clickable */
|
|
||||||
onClick?: () => void;
|
|
||||||
/** MUI sx prop for style overrides */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)';
|
|
||||||
const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)';
|
|
||||||
const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact vertical card for the FA design system.
|
|
||||||
*
|
|
||||||
* A smaller, flexible card for displaying providers, venues, or packages
|
|
||||||
* in grids, recommendation rows, and map popups. Shows an image with
|
|
||||||
* a title and optional meta, price, badges, and chips.
|
|
||||||
*
|
|
||||||
* Content hierarchy: **title → meta → price → chips/badges**.
|
|
||||||
*
|
|
||||||
* Verified providers show a small icon-only badge floating in the
|
|
||||||
* image (top-right). Truncated titles show a tooltip on hover with
|
|
||||||
* the full text.
|
|
||||||
*
|
|
||||||
* Composes: Card + Typography + Badge + Tooltip.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```tsx
|
|
||||||
* <MiniCard
|
|
||||||
* title="H.Parsons Funeral Directors"
|
|
||||||
* imageUrl="/images/parsons.jpg"
|
|
||||||
* verified
|
|
||||||
* price={900}
|
|
||||||
* location="Wollongong"
|
|
||||||
* rating={4.8}
|
|
||||||
* onClick={() => navigate('/providers/parsons')}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
imageUrl,
|
|
||||||
imageAlt,
|
|
||||||
verified = false,
|
|
||||||
price,
|
|
||||||
priceLabel,
|
|
||||||
location,
|
|
||||||
rating,
|
|
||||||
capacity,
|
|
||||||
badges,
|
|
||||||
chips,
|
|
||||||
selected = false,
|
|
||||||
onClick,
|
|
||||||
sx,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const hasMeta = location != null || rating != null || capacity != null;
|
|
||||||
const hasPrice = price != null || priceLabel != null;
|
|
||||||
|
|
||||||
// Detect title truncation for tooltip
|
|
||||||
const titleRef = React.useRef<HTMLElement>(null);
|
|
||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = titleRef.current;
|
|
||||||
if (el) {
|
|
||||||
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
|
|
||||||
}
|
|
||||||
}, [title]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
ref={ref}
|
|
||||||
interactive={!!onClick}
|
|
||||||
selected={selected}
|
|
||||||
padding="none"
|
|
||||||
onClick={onClick}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
overflow: 'hidden',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'background.paper',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* ── Image ── */}
|
|
||||||
<Box
|
|
||||||
role="img"
|
|
||||||
aria-label={imageAlt ?? title}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
height: IMAGE_HEIGHT,
|
|
||||||
backgroundImage: `url(${imageUrl})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundColor: 'var(--fa-color-neutral-100)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Verified icon badge — floating top-right */}
|
|
||||||
{verified && (
|
|
||||||
<Tooltip title="Verified provider" arrow placement="top">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'var(--fa-color-brand-600)',
|
|
||||||
color: 'var(--fa-color-white)',
|
|
||||||
boxShadow: 'var(--fa-shadow-sm)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Content ── */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: CONTENT_GAP,
|
|
||||||
p: CONTENT_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 1. Title — with tooltip when truncated */}
|
|
||||||
<Tooltip
|
|
||||||
title={isTruncated ? title : ''}
|
|
||||||
arrow
|
|
||||||
placement="top"
|
|
||||||
enterDelay={300}
|
|
||||||
disableHoverListener={!isTruncated}
|
|
||||||
>
|
|
||||||
<Typography ref={titleRef} variant="h6" maxLines={2}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 2. Meta row: location / rating / capacity */}
|
|
||||||
{hasMeta && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{location && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'text.secondary' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{location}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rating != null && (
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
|
||||||
aria-label={`Rated ${rating} out of 5`}
|
|
||||||
>
|
|
||||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{rating}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capacity != null && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<PeopleOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{capacity} guests
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 3. Price */}
|
|
||||||
{hasPrice && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
|
||||||
{priceLabel ? (
|
|
||||||
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
{priceLabel}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
From
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
component="span"
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
${price!.toLocaleString('en-AU')}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 4. Badges */}
|
|
||||||
{badges && badges.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{badges.map((badge) => (
|
|
||||||
<Badge
|
|
||||||
key={badge.label}
|
|
||||||
color={badge.color}
|
|
||||||
variant={badge.variant}
|
|
||||||
size="small"
|
|
||||||
icon={badge.icon}
|
|
||||||
>
|
|
||||||
{badge.label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 5. Chips */}
|
|
||||||
{chips && chips.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
||||||
{chips.map((chip) => (
|
|
||||||
<Badge key={chip} color="default" variant="soft" size="small">
|
|
||||||
{chip}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
MiniCard.displayName = 'MiniCard';
|
|
||||||
export default MiniCard;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { MiniCard, default } from './MiniCard';
|
|
||||||
export type { MiniCardProps, MiniCardBadge } from './MiniCard';
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonTable } from './ComparisonTable';
|
|
||||||
import type { ComparisonPackage } from './ComparisonTable';
|
|
||||||
|
|
||||||
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pkgWollongong: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount — upgrade options available.',
|
|
||||||
value: { type: 'allowance', amount: 1750 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Statutory medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium: Mackay Family Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'Lodgement with NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dressing Fee',
|
|
||||||
info: 'Dressing and preparation of the deceased.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'NSW Government cremation levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care of the deceased.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination of all funeral arrangements.',
|
|
||||||
value: { type: 'price', amount: 3650.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Transfer of the deceased.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording of the Funeral Service',
|
|
||||||
info: 'Professional video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{
|
|
||||||
name: 'Viewing Fee',
|
|
||||||
info: 'One private family viewing.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Flowers',
|
|
||||||
info: 'Seasonal floral arrangements.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Professional celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 550 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Additional fee for Saturday services.',
|
|
||||||
value: { type: 'price', amount: 880 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgMackay: ComparisonPackage = {
|
|
||||||
id: 'mackay-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 5495.45,
|
|
||||||
provider: {
|
|
||||||
name: 'Mackay Family Funerals',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 87,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount — upgrade options available.',
|
|
||||||
value: { type: 'allowance', amount: 1500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Statutory medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium: Mackay Family Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'Lodgement with NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'NSW Government cremation levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination of arrangements.',
|
|
||||||
value: { type: 'price', amount: 2430.35 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Transfer of the deceased.',
|
|
||||||
value: { type: 'price', amount: 0 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording of the Funeral Service',
|
|
||||||
info: 'Professional video recording.',
|
|
||||||
value: { type: 'unknown' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
|
||||||
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Professional celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 450 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Additional fee for Saturday services.',
|
|
||||||
value: { type: 'price', amount: 750 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgInglewood: ComparisonPackage = {
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Crematorium: Mackay Family Crematorium', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{ name: 'Digital Recording of the Funeral Service', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Catering', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgRecommended: ComparisonPackage = {
|
|
||||||
...pkgWollongong,
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
isRecommended: true,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgNoItemised: ComparisonPackage = {
|
|
||||||
id: 'no-data',
|
|
||||||
name: 'Basic Cremation',
|
|
||||||
price: 4500,
|
|
||||||
provider: {
|
|
||||||
name: 'Smith & Sons',
|
|
||||||
location: 'Bankstown',
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [],
|
|
||||||
itemizedAvailable: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonTable> = {
|
|
||||||
title: 'Organisms/ComparisonTable',
|
|
||||||
component: ComparisonTable,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1200, mx: 'auto' }}>
|
|
||||||
<Story />
|
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonTable>;
|
|
||||||
|
|
||||||
// --- Default (3 packages) ----------------------------------------------------
|
|
||||||
|
|
||||||
/** Three packages from different providers — full comparison */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Two Packages ------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Minimal two-column comparison */
|
|
||||||
export const TwoPackages: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- With Recommended --------------------------------------------------------
|
|
||||||
|
|
||||||
/** 3 user + 1 recommended = 4 columns — recommended has warm bg + badge */
|
|
||||||
export const WithRecommended: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood, pkgRecommended],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Mixed Verified/Unverified -----------------------------------------------
|
|
||||||
|
|
||||||
/** Mix of verified (Make Arrangement) and unverified (Make Enquiry) providers */
|
|
||||||
export const MixedVerified: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgInglewood],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Missing Itemised Data ---------------------------------------------------
|
|
||||||
|
|
||||||
/** One provider has no itemised breakdown — cells show "—" */
|
|
||||||
export const MissingData: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Card } from '../../atoms/Card';
|
|
||||||
import { ComparisonColumnCard } from '../../molecules/ComparisonColumnCard';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Cell value types for the comparison table */
|
|
||||||
export type ComparisonCellValue =
|
|
||||||
| { type: 'price'; amount: number }
|
|
||||||
| { type: 'allowance'; amount: number }
|
|
||||||
| { type: 'complimentary' }
|
|
||||||
| { type: 'included' }
|
|
||||||
| { type: 'poa' }
|
|
||||||
| { type: 'unknown' }
|
|
||||||
| { type: 'unavailable' };
|
|
||||||
|
|
||||||
export interface ComparisonLineItem {
|
|
||||||
name: string;
|
|
||||||
info?: string;
|
|
||||||
value: ComparisonCellValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonSection {
|
|
||||||
heading: string;
|
|
||||||
items: ComparisonLineItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonProvider {
|
|
||||||
name: string;
|
|
||||||
location: string;
|
|
||||||
logoUrl?: string;
|
|
||||||
rating?: number;
|
|
||||||
reviewCount?: number;
|
|
||||||
verified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonPackage {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
provider: ComparisonProvider;
|
|
||||||
sections: ComparisonSection[];
|
|
||||||
isRecommended?: boolean;
|
|
||||||
itemizedAvailable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonTableProps {
|
|
||||||
packages: ComparisonPackage[];
|
|
||||||
onArrange: (packageId: string) => void;
|
|
||||||
onRemove: (packageId: string) => void;
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function formatPrice(amount: number): string {
|
|
||||||
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CellValue({ value }: { value: ComparisonCellValue }) {
|
|
||||||
switch (value.type) {
|
|
||||||
case 'price':
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
|
||||||
{formatPrice(value.amount)}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'allowance':
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
|
||||||
{formatPrice(value.amount)}*
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'complimentary':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<CheckCircleOutlineIcon
|
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
Complimentary
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'included':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<CheckCircleOutlineIcon
|
|
||||||
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
Included
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'poa':
|
|
||||||
return (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
Price On Application
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
case 'unknown':
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ color: 'var(--fa-color-neutral-500)', fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
Unknown
|
|
||||||
</Typography>
|
|
||||||
<InfoOutlinedIcon
|
|
||||||
sx={{ fontSize: 14, color: 'var(--fa-color-neutral-500)' }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case 'unavailable':
|
|
||||||
return (
|
|
||||||
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
|
|
||||||
—
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMergedSections(
|
|
||||||
packages: ComparisonPackage[],
|
|
||||||
): { heading: string; items: { name: string; info?: string }[] }[] {
|
|
||||||
const sectionMap = new Map<string, { name: string; info?: string }[]>();
|
|
||||||
const sectionOrder: string[] = [];
|
|
||||||
|
|
||||||
for (const pkg of packages) {
|
|
||||||
if (pkg.itemizedAvailable === false) continue;
|
|
||||||
for (const section of pkg.sections) {
|
|
||||||
if (!sectionMap.has(section.heading)) {
|
|
||||||
sectionMap.set(section.heading, []);
|
|
||||||
sectionOrder.push(section.heading);
|
|
||||||
}
|
|
||||||
const existing = sectionMap.get(section.heading)!;
|
|
||||||
for (const item of section.items) {
|
|
||||||
if (!existing.some((e) => e.name === item.name)) {
|
|
||||||
existing.push({ name: item.name, info: item.info });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sectionOrder.map((heading) => ({
|
|
||||||
heading,
|
|
||||||
items: sectionMap.get(heading) ?? [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookupValue(
|
|
||||||
pkg: ComparisonPackage,
|
|
||||||
sectionHeading: string,
|
|
||||||
itemName: string,
|
|
||||||
): ComparisonCellValue {
|
|
||||||
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
|
|
||||||
const section = pkg.sections.find((s) => s.heading === sectionHeading);
|
|
||||||
if (!section) return { type: 'unavailable' };
|
|
||||||
const item = section.items.find((i) => i.name === itemName);
|
|
||||||
if (!item) return { type: 'unavailable' };
|
|
||||||
return item.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Section heading with left accent border */
|
|
||||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
|
||||||
px: 3,
|
|
||||||
py: 2.5,
|
|
||||||
borderLeft: '3px solid',
|
|
||||||
borderLeftColor: 'var(--fa-color-brand-500)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" component="h3">
|
|
||||||
{children}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reusable bordered table wrapper */
|
|
||||||
const tableSx = {
|
|
||||||
display: 'grid',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Side-by-side package comparison table for the FA design system.
|
|
||||||
*
|
|
||||||
* Info card in top-left column, floating verified badges above cards,
|
|
||||||
* section tables with left accent borders, no reviews table (rating in cards).
|
|
||||||
*
|
|
||||||
* Desktop only — ComparisonPage handles the mobile card view.
|
|
||||||
*/
|
|
||||||
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
|
|
||||||
({ packages, onArrange, onRemove, sx }, ref) => {
|
|
||||||
const colCount = packages.length + 1;
|
|
||||||
const mergedSections = buildMergedSections(packages);
|
|
||||||
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
|
|
||||||
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
ref={ref}
|
|
||||||
role="table"
|
|
||||||
aria-label="Package comparison"
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
display: { xs: 'none', md: 'block' },
|
|
||||||
overflowX: 'auto',
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Box sx={{ minWidth: minW }}>
|
|
||||||
{/* ── Package header cards ── */}
|
|
||||||
<Box
|
|
||||||
role="row"
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: gridCols,
|
|
||||||
gap: 2,
|
|
||||||
mb: 4,
|
|
||||||
alignItems: 'stretch',
|
|
||||||
pt: 3, // Room for floating verified badges
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Info card — stretches to match package card height, text at top */}
|
|
||||||
<Card
|
|
||||||
role="columnheader"
|
|
||||||
variant="elevated"
|
|
||||||
padding="default"
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="label" sx={{ fontWeight: 700, display: 'block', mb: 1 }}>
|
|
||||||
Package Comparison
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ lineHeight: 1.5, display: 'block' }}
|
|
||||||
>
|
|
||||||
Review and compare features side-by-side to find the right fit.
|
|
||||||
</Typography>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Package column header cards */}
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<ComparisonColumnCard
|
|
||||||
key={pkg.id}
|
|
||||||
pkg={pkg}
|
|
||||||
onArrange={onArrange}
|
|
||||||
onRemove={onRemove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ── Section tables (each separate with left accent headings) ── */}
|
|
||||||
{mergedSections.map((section) => (
|
|
||||||
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
|
||||||
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
|
||||||
<SectionHeading>{section.heading}</SectionHeading>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<Box
|
|
||||||
key={item.name}
|
|
||||||
role="row"
|
|
||||||
sx={{
|
|
||||||
gridColumn: `1 / ${colCount + 1}`,
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'subgrid',
|
|
||||||
transition: 'background-color 0.15s ease',
|
|
||||||
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
role="cell"
|
|
||||||
sx={{
|
|
||||||
px: 3,
|
|
||||||
py: 2,
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" color="text.secondary" component="span">
|
|
||||||
{item.name}
|
|
||||||
</Typography>
|
|
||||||
{item.info && (
|
|
||||||
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{'\u00A0'}
|
|
||||||
<Tooltip title={item.info} arrow placement="top">
|
|
||||||
<InfoOutlinedIcon
|
|
||||||
aria-label={`More information about ${item.name}`}
|
|
||||||
sx={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--fa-color-neutral-400)',
|
|
||||||
cursor: 'help',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<Box
|
|
||||||
key={pkg.id}
|
|
||||||
role="cell"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
px: 2,
|
|
||||||
py: 2,
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderLeft: '1px solid',
|
|
||||||
borderLeftColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
|
||||||
* Some providers have not provided an itemised pricing breakdown. Their items are
|
|
||||||
shown as "—" above.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonTable.displayName = 'ComparisonTable';
|
|
||||||
export default ComparisonTable;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export { ComparisonTable, default } from './ComparisonTable';
|
|
||||||
export type {
|
|
||||||
ComparisonTableProps,
|
|
||||||
ComparisonPackage,
|
|
||||||
ComparisonProvider,
|
|
||||||
ComparisonSection,
|
|
||||||
ComparisonLineItem,
|
|
||||||
ComparisonCellValue,
|
|
||||||
} from './ComparisonTable';
|
|
||||||
@@ -41,6 +41,10 @@ export interface FuneralFinderV3Props {
|
|||||||
onSearch?: (params: FuneralFinderV3SearchParams) => void;
|
onSearch?: (params: FuneralFinderV3SearchParams) => void;
|
||||||
/** Shows loading state on the CTA */
|
/** Shows loading state on the CTA */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** Optional heading override */
|
||||||
|
heading?: string;
|
||||||
|
/** Optional subheading override */
|
||||||
|
subheading?: string;
|
||||||
/** MUI sx override for the root container */
|
/** MUI sx override for the root container */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -247,7 +251,13 @@ const selectMenuProps = {
|
|||||||
*/
|
*/
|
||||||
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
|
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { onSearch, loading = false, sx } = props;
|
const {
|
||||||
|
onSearch,
|
||||||
|
loading = false,
|
||||||
|
heading = 'Find funeral directors near you',
|
||||||
|
subheading,
|
||||||
|
sx,
|
||||||
|
} = props;
|
||||||
|
|
||||||
// ─── IDs for aria-labelledby ──────────────────────────────
|
// ─── IDs for aria-labelledby ──────────────────────────────
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
@@ -382,6 +392,29 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
|||||||
...(Array.isArray(sx) ? sx : [sx]),
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{/* ── Header ──────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography
|
||||||
|
variant="h3"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'var(--fa-font-family-display)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||||
|
mb: subheading ? 1 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</Typography>
|
||||||
|
{subheading && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{subheading}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
{/* ── How can we help ─────────────────────────────────── */}
|
{/* ── How can we help ─────────────────────────────────── */}
|
||||||
<Box ref={statusSectionRef}>
|
<Box ref={statusSectionRef}>
|
||||||
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
|
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
|
||||||
@@ -528,7 +561,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
|||||||
placeholder="Enter suburb or postcode"
|
placeholder="Enter suburb or postcode"
|
||||||
inputRef={locationInputRef}
|
inputRef={locationInputRef}
|
||||||
startAdornment={
|
startAdornment={
|
||||||
<InputAdornment position="start" sx={{ ml: 0.25, mr: -0.5 }}>
|
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||||
<LocationOnOutlinedIcon
|
<LocationOnOutlinedIcon
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -544,7 +577,6 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
|||||||
...fieldBaseSx,
|
...fieldBaseSx,
|
||||||
'& .MuiOutlinedInput-input': {
|
'& .MuiOutlinedInput-input': {
|
||||||
...fieldInputStyles,
|
...fieldInputStyles,
|
||||||
pl: 0.75,
|
|
||||||
'&::placeholder': {
|
'&::placeholder': {
|
||||||
color: 'var(--fa-color-text-disabled)',
|
color: 'var(--fa-color-text-disabled)',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -585,12 +617,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
sx={{ minHeight: { xs: 40, sm: 52 }, fontSize: { xs: '0.875rem', sm: undefined } }}
|
sx={{ minHeight: 52 }}
|
||||||
>
|
>
|
||||||
Search
|
Search Local Providers
|
||||||
</Button>
|
</Button>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="captionSm"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -143,28 +143,3 @@ export const ExtendedNavigation: Story = {
|
|||||||
ctaLabel: 'Start planning',
|
ctaLabel: 'Start planning',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- With Dropdown -----------------------------------------------------------
|
|
||||||
|
|
||||||
/** Items with `children` render as a dropdown on desktop and a collapsible
|
|
||||||
* section in the mobile drawer */
|
|
||||||
export const WithDropdown: Story = {
|
|
||||||
args: {
|
|
||||||
logo: <FALogo />,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Locations',
|
|
||||||
children: [
|
|
||||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
|
||||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
|
||||||
{ label: 'Sydney', href: '/locations/sydney' },
|
|
||||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
|
||||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -6,14 +6,9 @@ import Drawer from '@mui/material/Drawer';
|
|||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import Menu from '@mui/material/Menu';
|
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
|
||||||
import Collapse from '@mui/material/Collapse';
|
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { IconButton } from '../../atoms/IconButton';
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
import { Link } from '../../atoms/Link';
|
import { Link } from '../../atoms/Link';
|
||||||
@@ -23,16 +18,14 @@ import { Divider } from '../../atoms/Divider';
|
|||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A navigation link item. May have children to render as a dropdown. */
|
/** A navigation link item */
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
/** Display label */
|
/** Display label */
|
||||||
label: string;
|
label: string;
|
||||||
/** URL to navigate to (ignored when `children` is provided) */
|
/** URL to navigate to */
|
||||||
href?: string;
|
href: string;
|
||||||
/** Click handler (alternative to href for SPA navigation) */
|
/** Click handler (alternative to href for SPA navigation) */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
/** Sub-items rendered as a dropdown (desktop) or collapsible (mobile) */
|
|
||||||
children?: NavItem[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for the FA Navigation organism */
|
/** Props for the FA Navigation organism */
|
||||||
@@ -51,163 +44,6 @@ export interface NavigationProps {
|
|||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Desktop dropdown link ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DesktopDropdownProps {
|
|
||||||
item: NavItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DesktopDropdown: React.FC<DesktopDropdownProps> = ({ item }) => {
|
|
||||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
|
|
||||||
const handleOpen = (event: React.MouseEvent<HTMLElement>) => setAnchorEl(event.currentTarget);
|
|
||||||
const handleClose = () => setAnchorEl(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={handleOpen}
|
|
||||||
sx={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'var(--fa-color-brand-900)',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '1rem',
|
|
||||||
'&:hover': {
|
|
||||||
color: 'primary.main',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Box>
|
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
mt: 1,
|
|
||||||
minWidth: 200,
|
|
||||||
borderRadius: 'var(--fa-border-radius-md, 8px)',
|
|
||||||
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.children?.map((child) => (
|
|
||||||
<MenuItem
|
|
||||||
key={child.label}
|
|
||||||
component="a"
|
|
||||||
href={child.href}
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
if (child.onClick) {
|
|
||||||
e.preventDefault();
|
|
||||||
child.onClick();
|
|
||||||
}
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
color: 'var(--fa-color-brand-900)',
|
|
||||||
fontWeight: 500,
|
|
||||||
py: 1.25,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'var(--fa-color-brand-100)',
|
|
||||||
color: 'primary.main',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{child.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Mobile collapsible item ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface MobileCollapsibleProps {
|
|
||||||
item: NavItem;
|
|
||||||
onItemClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick }) => {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ListItemButton
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
aria-expanded={open}
|
|
||||||
sx={{
|
|
||||||
py: 1.5,
|
|
||||||
px: 3,
|
|
||||||
minHeight: 44,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'var(--fa-color-brand-100)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={item.label}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
||||||
</ListItemButton>
|
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
|
||||||
<List component="div" disablePadding>
|
|
||||||
{item.children?.map((child) => (
|
|
||||||
<ListItemButton
|
|
||||||
key={child.label}
|
|
||||||
component="a"
|
|
||||||
href={child.href}
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
if (child.onClick) {
|
|
||||||
e.preventDefault();
|
|
||||||
child.onClick();
|
|
||||||
}
|
|
||||||
onItemClick();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
py: 1.25,
|
|
||||||
pl: 5,
|
|
||||||
pr: 3,
|
|
||||||
minHeight: 44,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'var(--fa-color-brand-100)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={child.label}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
fontWeight: 400,
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
color: 'text.secondary',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,13 +51,26 @@ const MobileCollapsible: React.FC<MobileCollapsibleProps> = ({ item, onItemClick
|
|||||||
*
|
*
|
||||||
* Responsive header with logo, navigation links, and optional CTA.
|
* Responsive header with logo, navigation links, and optional CTA.
|
||||||
* Desktop shows links inline; mobile collapses to hamburger + drawer.
|
* Desktop shows links inline; mobile collapses to hamburger + drawer.
|
||||||
* Items with `children` render as a dropdown (desktop) or collapsible
|
|
||||||
* section (mobile).
|
|
||||||
*
|
*
|
||||||
* Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
|
* Maps to Figma "Main Nav" (14:108) desktop and "Mobile Header"
|
||||||
* (2391:41508) mobile patterns.
|
* (2391:41508) mobile patterns.
|
||||||
*
|
*
|
||||||
* Composes AppBar + Link + IconButton + Button + Divider + Drawer + Menu.
|
* Composes AppBar + Link + IconButton + Button + Divider + Drawer.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <Navigation
|
||||||
|
* logo={<img src="/logo.svg" alt="Funeral Arranger" height={40} />}
|
||||||
|
* onLogoClick={() => navigate('/')}
|
||||||
|
* items={[
|
||||||
|
* { label: 'FAQ', href: '/faq' },
|
||||||
|
* { label: 'Contact Us', href: '/contact' },
|
||||||
|
* { label: 'Log in', href: '/login' },
|
||||||
|
* ]}
|
||||||
|
* ctaLabel="Start planning"
|
||||||
|
* onCtaClick={() => navigate('/arrange')}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||||
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
|
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
|
||||||
@@ -229,7 +78,6 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
|||||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
|
||||||
|
|
||||||
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
|
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
|
||||||
const closeDrawer = () => setDrawerOpen(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -299,28 +147,24 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
|||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
||||||
>
|
>
|
||||||
{items.map((item) =>
|
{items.map((item) => (
|
||||||
item.children && item.children.length > 0 ? (
|
<Link
|
||||||
<DesktopDropdown key={item.label} item={item} />
|
key={item.label}
|
||||||
) : (
|
href={item.href}
|
||||||
<Link
|
onClick={item.onClick}
|
||||||
key={item.label}
|
underline="hover"
|
||||||
href={item.href}
|
sx={{
|
||||||
onClick={item.onClick}
|
color: 'var(--fa-color-brand-900)',
|
||||||
underline="hover"
|
fontWeight: 600,
|
||||||
sx={{
|
fontSize: '1rem',
|
||||||
color: 'var(--fa-color-brand-900)',
|
'&:hover': {
|
||||||
fontWeight: 600,
|
color: 'primary.main',
|
||||||
fontSize: '1rem',
|
},
|
||||||
'&:hover': {
|
}}
|
||||||
color: 'primary.main',
|
>
|
||||||
},
|
{item.label}
|
||||||
}}
|
</Link>
|
||||||
>
|
))}
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctaLabel && (
|
{ctaLabel && (
|
||||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||||
@@ -366,40 +210,36 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<List component="nav" aria-label="Main navigation">
|
<List component="nav" aria-label="Main navigation">
|
||||||
{items.map((item) =>
|
{items.map((item) => (
|
||||||
item.children && item.children.length > 0 ? (
|
<ListItemButton
|
||||||
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} />
|
key={item.label}
|
||||||
) : (
|
component="a"
|
||||||
<ListItemButton
|
href={item.href}
|
||||||
key={item.label}
|
onClick={(e: React.MouseEvent) => {
|
||||||
component="a"
|
if (item.onClick) {
|
||||||
href={item.href}
|
e.preventDefault();
|
||||||
onClick={(e: React.MouseEvent) => {
|
item.onClick();
|
||||||
if (item.onClick) {
|
}
|
||||||
e.preventDefault();
|
setDrawerOpen(false);
|
||||||
item.onClick();
|
}}
|
||||||
}
|
sx={{
|
||||||
closeDrawer();
|
py: 1.5,
|
||||||
|
px: 3,
|
||||||
|
minHeight: 44,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'var(--fa-color-brand-100)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '1rem',
|
||||||
}}
|
}}
|
||||||
sx={{
|
/>
|
||||||
py: 1.5,
|
</ListItemButton>
|
||||||
px: 3,
|
))}
|
||||||
minHeight: 44,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'var(--fa-color-brand-100)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={item.label}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
{ctaLabel && (
|
{ctaLabel && (
|
||||||
@@ -410,7 +250,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
|||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onCtaClick) onCtaClick();
|
if (onCtaClick) onCtaClick();
|
||||||
closeDrawer();
|
setDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
|
|||||||
@@ -15,101 +15,97 @@ const DEMO_IMAGE =
|
|||||||
|
|
||||||
const essentials = [
|
const essentials = [
|
||||||
{
|
{
|
||||||
name: 'Allowance for Coffin',
|
name: 'Accommodation',
|
||||||
price: 1750,
|
price: 1500,
|
||||||
isAllowance: true,
|
info: 'Refrigerated holding of the deceased prior to the funeral service.',
|
||||||
info: 'Allowance amount — upgrade options available during arrangement.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
price: 350,
|
|
||||||
info: 'Statutory medical referee fee required for all cremations in NSW.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium: Mackay Family Crematorium',
|
|
||||||
price: 660,
|
|
||||||
info: 'Cremation facility fees at the selected crematorium.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Death Registration Certificate',
|
name: 'Death Registration Certificate',
|
||||||
price: 70,
|
price: 1500,
|
||||||
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
|
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Dressing Fee',
|
name: 'Doctor Fee for Cremation',
|
||||||
price: 0,
|
price: 1500,
|
||||||
priceLabel: 'Complimentary',
|
info: 'Statutory medical referee fee required for all cremations in NSW.',
|
||||||
info: 'Dressing and preparation of the deceased — included at no charge.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'NSW Government Levy — Cremation',
|
name: 'NSW Government Levy — Cremation',
|
||||||
price: 45.1,
|
price: 1500,
|
||||||
info: 'NSW Government cremation levy as set by the Department of Health.',
|
info: 'NSW Government cremation levy as set by the Department of Health.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Professional Mortuary Care',
|
name: 'Professional Mortuary Care',
|
||||||
price: 440,
|
price: 1500,
|
||||||
info: 'Preparation and care of the deceased.',
|
info: 'Preparation and care of the deceased.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Professional Service Fee',
|
name: 'Professional Service Fee',
|
||||||
price: 3650.9,
|
price: 1500,
|
||||||
info: 'Coordination of all funeral arrangements and services.',
|
info: 'Coordination of all funeral arrangements and services.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Transportation Service Fee',
|
name: 'Allowance for Coffin',
|
||||||
price: 0,
|
price: 1500,
|
||||||
priceLabel: 'Complimentary',
|
isAllowance: true,
|
||||||
info: 'Transfer of the deceased to the funeral home — included in this package.',
|
info: 'Allowance amount — upgrade options available during arrangement.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Allowance for Crematorium',
|
||||||
|
price: 1500,
|
||||||
|
isAllowance: true,
|
||||||
|
info: 'Allowance for crematorium fees — varies by location.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Allowance for Hearse',
|
||||||
|
price: 1500,
|
||||||
|
isAllowance: true,
|
||||||
|
info: 'Allowance for hearse transfer — distance surcharges may apply.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const optionals = [
|
const complimentary = [
|
||||||
{
|
{
|
||||||
name: 'Digital Recording of the Funeral Service',
|
name: 'Dressing Fee',
|
||||||
priceLabel: 'Complimentary',
|
info: 'Dressing and preparation of the deceased — included at no charge.',
|
||||||
info: 'Professional video recording of the funeral service.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Online Notice',
|
|
||||||
priceLabel: 'Complimentary',
|
|
||||||
info: 'Online death notice published on the funeral home website.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Viewing Fee',
|
|
||||||
priceLabel: 'Complimentary',
|
|
||||||
info: 'One private family viewing — included at no charge.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Webstreaming of the Funeral Service',
|
|
||||||
priceLabel: 'Complimentary',
|
|
||||||
info: 'Live webstream of the funeral service for remote attendees.',
|
|
||||||
},
|
},
|
||||||
|
{ name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const extras = {
|
const extras = {
|
||||||
heading: 'Extras',
|
heading: 'Extras',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'Allowance for Celebrant',
|
name: 'Allowance for Flowers',
|
||||||
price: 550,
|
price: 1500,
|
||||||
|
isAllowance: true,
|
||||||
|
info: 'Seasonal floral arrangements for the service.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Allowance for Master of Ceremonies',
|
||||||
|
price: 1500,
|
||||||
isAllowance: true,
|
isAllowance: true,
|
||||||
info: 'Professional celebrant or MC for the funeral service.',
|
info: 'Professional celebrant or MC for the funeral service.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Catering',
|
name: 'After Business Hours Service Surcharge',
|
||||||
priceLabel: 'Price On Application',
|
price: 1500,
|
||||||
info: 'Catering for the wake or post-service gathering.',
|
info: 'Additional fee for services held outside standard business hours.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Newspaper Notice',
|
name: 'After Hours Prayers',
|
||||||
priceLabel: 'Price On Application',
|
price: 1500,
|
||||||
info: 'Published death notice in local or national newspaper.',
|
info: 'Evening prayer service at the funeral home.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Saturday Service Fee',
|
name: 'Coffin Bearing by Funeral Directors',
|
||||||
price: 880,
|
price: 1500,
|
||||||
info: 'Additional fee for services held on a Saturday.',
|
info: 'Professional pallbearing by funeral directors.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Digital Recording',
|
||||||
|
price: 1500,
|
||||||
|
info: 'Professional video recording of the funeral service.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -173,16 +169,16 @@ type Story = StoryObj<typeof PackageDetail>;
|
|||||||
|
|
||||||
// --- Default -----------------------------------------------------------------
|
// --- Default -----------------------------------------------------------------
|
||||||
|
|
||||||
/** Full package detail panel — Essentials, Optionals, Total, then Extras */
|
/** Full package detail panel — Essentials, Complimentary, Total, then Extras */
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Traditional Family Cremation Service',
|
name: 'Everyday Funeral Package',
|
||||||
price: 6966,
|
price: 900,
|
||||||
sections: [
|
sections: [
|
||||||
{ heading: 'Essentials', items: essentials },
|
{ heading: 'Essentials', items: essentials },
|
||||||
{ heading: 'Optionals', items: optionals },
|
{ heading: 'Complimentary Items', items: complimentary },
|
||||||
],
|
],
|
||||||
total: 6966,
|
total: 2700,
|
||||||
extras,
|
extras,
|
||||||
terms: termsText,
|
terms: termsText,
|
||||||
onArrange: () => alert('Make Arrangement clicked'),
|
onArrange: () => alert('Make Arrangement clicked'),
|
||||||
@@ -195,10 +191,10 @@ export const Default: Story = {
|
|||||||
/** Compare button in loading state — adding to comparison cart */
|
/** Compare button in loading state — adding to comparison cart */
|
||||||
export const CompareLoading: Story = {
|
export const CompareLoading: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Traditional Family Cremation Service',
|
name: 'Everyday Funeral Package',
|
||||||
price: 6966,
|
price: 900,
|
||||||
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
||||||
total: 6966,
|
total: 6000,
|
||||||
onArrange: () => alert('Make Arrangement'),
|
onArrange: () => alert('Make Arrangement'),
|
||||||
onCompare: () => {},
|
onCompare: () => {},
|
||||||
compareLoading: true,
|
compareLoading: true,
|
||||||
@@ -207,16 +203,16 @@ export const CompareLoading: Story = {
|
|||||||
|
|
||||||
// --- Without Extras ----------------------------------------------------------
|
// --- Without Extras ----------------------------------------------------------
|
||||||
|
|
||||||
/** Simpler package with essentials and optionals only — no extras */
|
/** Simpler package with essentials and complimentary only */
|
||||||
export const WithoutExtras: Story = {
|
export const WithoutExtras: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Essential Cremation Package',
|
name: 'Essential Funeral Package',
|
||||||
price: 4850,
|
price: 600,
|
||||||
sections: [
|
sections: [
|
||||||
{ heading: 'Essentials', items: essentials.slice(0, 6) },
|
{ heading: 'Essentials', items: essentials.slice(0, 6) },
|
||||||
{ heading: 'Optionals', items: optionals.slice(0, 2) },
|
{ heading: 'Complimentary Items', items: complimentary },
|
||||||
],
|
],
|
||||||
total: 4850,
|
total: 9000,
|
||||||
terms: termsText,
|
terms: termsText,
|
||||||
onArrange: () => alert('Make Arrangement'),
|
onArrange: () => alert('Make Arrangement'),
|
||||||
onCompare: () => alert('Compare'),
|
onCompare: () => alert('Compare'),
|
||||||
@@ -336,9 +332,9 @@ export const PackageSelectPage: Story = {
|
|||||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||||
sections={[
|
sections={[
|
||||||
{ heading: 'Essentials', items: essentials },
|
{ heading: 'Essentials', items: essentials },
|
||||||
{ heading: 'Optionals', items: optionals },
|
{ heading: 'Complimentary Items', items: complimentary },
|
||||||
]}
|
]}
|
||||||
total={6966}
|
total={2700}
|
||||||
extras={extras}
|
extras={extras}
|
||||||
terms={termsText}
|
terms={termsText}
|
||||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ export interface PackageLineItem {
|
|||||||
price?: number;
|
price?: number;
|
||||||
/** Whether this is an allowance (shows asterisk) */
|
/** Whether this is an allowance (shows asterisk) */
|
||||||
isAllowance?: boolean;
|
isAllowance?: boolean;
|
||||||
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
|
|
||||||
priceLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
|
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
|
||||||
@@ -85,7 +83,6 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
|
|||||||
info={item.info}
|
info={item.info}
|
||||||
price={item.price}
|
price={item.price}
|
||||||
isAllowance={item.isAllowance}
|
isAllowance={item.isAllowance}
|
||||||
priceLabel={item.priceLabel}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorP
|
|||||||
<Box
|
<Box
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={heading}
|
aria-label={heading}
|
||||||
aria-required={continueLabel ? true : undefined}
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonPage } from './ComparisonPage';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
|
||||||
|
|
||||||
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
|
|
||||||
|
|
||||||
const FALogoNav = () => (
|
|
||||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pkgWollongong: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount — upgrade options available.',
|
|
||||||
value: { type: 'allowance', amount: 1750 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Statutory medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'Lodgement with NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dressing Fee',
|
|
||||||
info: 'Dressing and preparation.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'NSW Government cremation levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination of arrangements.',
|
|
||||||
value: { type: 'price', amount: 3650.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Transfer of the deceased.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
info: 'Professional video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{
|
|
||||||
name: 'Viewing Fee',
|
|
||||||
info: 'One private family viewing.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Flowers',
|
|
||||||
info: 'Seasonal floral arrangements.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Professional celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 550 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Additional fee for Saturday services.',
|
|
||||||
value: { type: 'price', amount: 880 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgMackay: ComparisonPackage = {
|
|
||||||
id: 'mackay-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 5495.45,
|
|
||||||
provider: {
|
|
||||||
name: 'Mackay Family Funerals',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 87,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount.',
|
|
||||||
value: { type: 'allowance', amount: 1500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'Government levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination.',
|
|
||||||
value: { type: 'price', amount: 2430.35 },
|
|
||||||
},
|
|
||||||
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
|
||||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 450 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Saturday surcharge.',
|
|
||||||
value: { type: 'price', amount: 750 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgInglewood: ComparisonPackage = {
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Catering', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgRecommended: ComparisonPackage = {
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Premium coffin allowance.',
|
|
||||||
value: { type: 'allowance', amount: 2500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Premium crematorium.',
|
|
||||||
value: { type: 'price', amount: 850 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dressing Fee',
|
|
||||||
info: 'Dressing and preparation.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'Government levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Full preparation and care.',
|
|
||||||
value: { type: 'price', amount: 580 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Full coordination.',
|
|
||||||
value: { type: 'price', amount: 4054.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Premium transfer.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
info: 'HD video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Premium celebrant.',
|
|
||||||
value: { type: 'allowance', amount: 700 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Catering',
|
|
||||||
info: 'Full catering included.',
|
|
||||||
value: { type: 'price', amount: 1200 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Newspaper Notice',
|
|
||||||
info: 'Published death notice.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'No Saturday surcharge.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const defaultNav = (
|
|
||||||
<Navigation
|
|
||||||
logo={<FALogoNav />}
|
|
||||||
items={[
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonPage> = {
|
|
||||||
title: 'Pages/ComparisonPage',
|
|
||||||
component: ComparisonPage,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
navigation: defaultNav,
|
|
||||||
onShare: () => alert('Share'),
|
|
||||||
onPrint: () => window.print(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonPage>;
|
|
||||||
|
|
||||||
// --- Default (3 packages, desktop) -------------------------------------------
|
|
||||||
|
|
||||||
/** Three packages from different providers */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Two Packages ------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Minimal two-package comparison */
|
|
||||||
export const TwoPackages: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- With Recommended --------------------------------------------------------
|
|
||||||
|
|
||||||
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
|
|
||||||
export const WithRecommended: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
recommendedPackage: pkgRecommended,
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Mobile View -------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Mobile viewport — shows tabbed card view */
|
|
||||||
export const MobileView: Story = {
|
|
||||||
parameters: {
|
|
||||||
viewport: { defaultViewport: 'mobile1' },
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
recommendedPackage: pkgRecommended,
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Interactive (with remove) -----------------------------------------------
|
|
||||||
|
|
||||||
/** Interactive — remove packages from comparison */
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: (args) => {
|
|
||||||
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComparisonPage
|
|
||||||
{...args}
|
|
||||||
packages={pkgs}
|
|
||||||
recommendedPackage={pkgRecommended}
|
|
||||||
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
|
|
||||||
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
|
|
||||||
onBack={() => alert('Back to packages')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
|
||||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
|
||||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
|
||||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Props for the ComparisonPage */
|
|
||||||
export interface ComparisonPageProps {
|
|
||||||
/** User-selected packages to compare (max 3) */
|
|
||||||
packages: ComparisonPackage[];
|
|
||||||
/** System-recommended package — always shown as an additional column */
|
|
||||||
recommendedPackage?: ComparisonPackage;
|
|
||||||
/** Called when user clicks CTA on a package */
|
|
||||||
onArrange: (packageId: string) => void;
|
|
||||||
/** Called when user removes a package from comparison */
|
|
||||||
onRemove: (packageId: string) => void;
|
|
||||||
/** Called when user clicks Back */
|
|
||||||
onBack: () => void;
|
|
||||||
/** Called when user clicks Share */
|
|
||||||
onShare?: () => void;
|
|
||||||
/** Called when user clicks Print */
|
|
||||||
onPrint?: () => void;
|
|
||||||
/** Navigation bar slot */
|
|
||||||
navigation?: React.ReactNode;
|
|
||||||
/** MUI sx prop */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Package comparison page for the FA design system (V2 — production).
|
|
||||||
*
|
|
||||||
* Desktop: Full ComparisonTable with info card, floating verified badges,
|
|
||||||
* section tables with left accent borders. **Recommended package appears as
|
|
||||||
* the first (leftmost) column.**
|
|
||||||
* Mobile: Tabbed card view with horizontal tab rail. **Recommended package is
|
|
||||||
* the first tab in the rail, but the first user-selected package is the
|
|
||||||
* initially active tab** — the recommended tab is a suggestion, not the
|
|
||||||
* default view.
|
|
||||||
*
|
|
||||||
* Share + Print utility actions in the page header.
|
|
||||||
*
|
|
||||||
* See `ComparisonPageV1.tsx` for the archived V1 (recommended-last) layout.
|
|
||||||
*/
|
|
||||||
export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>(
|
|
||||||
(
|
|
||||||
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const tablistId = useId();
|
|
||||||
const railRef = useRef<HTMLDivElement>(null);
|
|
||||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
|
|
||||||
const allPackages = React.useMemo(() => {
|
|
||||||
const result: ComparisonPackage[] = [];
|
|
||||||
if (recommendedPackage) {
|
|
||||||
result.push({ ...recommendedPackage, isRecommended: true });
|
|
||||||
}
|
|
||||||
result.push(...packages);
|
|
||||||
return result;
|
|
||||||
}, [packages, recommendedPackage]);
|
|
||||||
|
|
||||||
// On mobile, default the active tab to the first user-selected package
|
|
||||||
// (not the recommended). Recommended is first in the rail as a suggestion.
|
|
||||||
const defaultTabIdx = recommendedPackage ? 1 : 0;
|
|
||||||
const [activeTabIdx, setActiveTabIdx] = useState(defaultTabIdx);
|
|
||||||
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
|
|
||||||
|
|
||||||
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
|
|
||||||
const subtitle =
|
|
||||||
providerCount > 1
|
|
||||||
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
|
|
||||||
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
|
|
||||||
|
|
||||||
const hasRecommended = allPackages.some((p) => p.isRecommended);
|
|
||||||
|
|
||||||
const scrollToCenter = useCallback((idx: number) => {
|
|
||||||
const tab = tabRefs.current[idx];
|
|
||||||
if (tab && railRef.current) {
|
|
||||||
const rail = railRef.current;
|
|
||||||
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
|
|
||||||
const railCenter = rail.offsetWidth / 2;
|
|
||||||
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTabClick = useCallback(
|
|
||||||
(idx: number) => {
|
|
||||||
setActiveTabIdx(idx);
|
|
||||||
scrollToCenter(idx);
|
|
||||||
},
|
|
||||||
[scrollToCenter],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Center the default tab on mount
|
|
||||||
React.useEffect(() => {
|
|
||||||
// Small delay to allow layout to settle
|
|
||||||
const timer = setTimeout(() => scrollToCenter(defaultTabIdx), 50);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} sx={sx}>
|
|
||||||
<WizardLayout
|
|
||||||
variant="wide-form"
|
|
||||||
navigation={navigation}
|
|
||||||
showBackLink
|
|
||||||
backLabel="Back"
|
|
||||||
onBack={onBack}
|
|
||||||
>
|
|
||||||
{/* Page header with Share/Print actions */}
|
|
||||||
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
|
||||||
Compare packages
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" aria-live="polite">
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Share + Print */}
|
|
||||||
{(onShare || onPrint) && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
|
||||||
{onShare && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
startIcon={<ShareOutlinedIcon />}
|
|
||||||
onClick={onShare}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onPrint && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
startIcon={<PrintOutlinedIcon />}
|
|
||||||
onClick={onPrint}
|
|
||||||
>
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Desktop: ComparisonTable */}
|
|
||||||
{!isMobile && (
|
|
||||||
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile: Tab rail + card view */}
|
|
||||||
{isMobile && allPackages.length > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Tab rail — mini cards showing provider + package + price */}
|
|
||||||
<Box
|
|
||||||
ref={railRef}
|
|
||||||
role="tablist"
|
|
||||||
id={tablistId}
|
|
||||||
aria-label="Packages to compare"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 1.5,
|
|
||||||
overflowX: 'auto',
|
|
||||||
py: 2,
|
|
||||||
px: 2,
|
|
||||||
mx: -2,
|
|
||||||
mt: 1,
|
|
||||||
mb: 3,
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allPackages.map((pkg, idx) => (
|
|
||||||
<ComparisonTabCard
|
|
||||||
key={pkg.id}
|
|
||||||
ref={(el: HTMLDivElement | null) => {
|
|
||||||
tabRefs.current[idx] = el;
|
|
||||||
}}
|
|
||||||
pkg={pkg}
|
|
||||||
isActive={idx === activeTabIdx}
|
|
||||||
hasRecommended={hasRecommended}
|
|
||||||
tabId={`comparison-tab-${idx}`}
|
|
||||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
|
||||||
onClick={() => handleTabClick(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{activePackage && (
|
|
||||||
<Box
|
|
||||||
role="tabpanel"
|
|
||||||
id={`comparison-tabpanel-${activeTabIdx}`}
|
|
||||||
aria-labelledby={`comparison-tab-${activeTabIdx}`}
|
|
||||||
>
|
|
||||||
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</WizardLayout>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonPage.displayName = 'ComparisonPage';
|
|
||||||
export default ComparisonPage;
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import { ComparisonPageV1 } from './ComparisonPageV1';
|
|
||||||
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
|
||||||
|
|
||||||
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
|
|
||||||
|
|
||||||
const FALogoNav = () => (
|
|
||||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Mock data ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pkgWollongong: ComparisonPackage = {
|
|
||||||
id: 'wollongong-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 6966,
|
|
||||||
provider: {
|
|
||||||
name: 'Wollongong City Funerals',
|
|
||||||
location: 'Wollongong',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: 122,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount — upgrade options available.',
|
|
||||||
value: { type: 'allowance', amount: 1750 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Statutory medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'Lodgement with NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dressing Fee',
|
|
||||||
info: 'Dressing and preparation.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'NSW Government cremation levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination of arrangements.',
|
|
||||||
value: { type: 'price', amount: 3650.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Transfer of the deceased.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
info: 'Professional video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{
|
|
||||||
name: 'Viewing Fee',
|
|
||||||
info: 'One private family viewing.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Flowers',
|
|
||||||
info: 'Seasonal floral arrangements.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Professional celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 550 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Additional fee for Saturday services.',
|
|
||||||
value: { type: 'price', amount: 880 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgMackay: ComparisonPackage = {
|
|
||||||
id: 'mackay-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 5495.45,
|
|
||||||
provider: {
|
|
||||||
name: 'Mackay Family Funerals',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: 87,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Allowance amount.',
|
|
||||||
value: { type: 'allowance', amount: 1500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Cremation facility fees.',
|
|
||||||
value: { type: 'price', amount: 660 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'Government levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Preparation and care.',
|
|
||||||
value: { type: 'price', amount: 440 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Coordination.',
|
|
||||||
value: { type: 'price', amount: 2430.35 },
|
|
||||||
},
|
|
||||||
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
|
||||||
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Celebrant or MC.',
|
|
||||||
value: { type: 'allowance', amount: 450 },
|
|
||||||
},
|
|
||||||
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'Saturday surcharge.',
|
|
||||||
value: { type: 'price', amount: 750 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgInglewood: ComparisonPackage = {
|
|
||||||
id: 'inglewood-everyday',
|
|
||||||
name: 'Everyday Funeral Package',
|
|
||||||
price: 7200,
|
|
||||||
provider: {
|
|
||||||
name: 'Inglewood Chapel',
|
|
||||||
location: 'Inglewood',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.2,
|
|
||||||
reviewCount: 45,
|
|
||||||
verified: false,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Coffin', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Cremation Certificate/Permit', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Crematorium', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Death Registration Certificate', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Dressing Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'NSW Government Levy — Cremation', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Mortuary Care', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Professional Service Fee', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Transportation Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{ name: 'Digital Recording', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Flowers', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Online Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Viewing Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{ name: 'Allowance for Celebrant', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Catering', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Newspaper Notice', value: { type: 'unknown' } },
|
|
||||||
{ name: 'Saturday Service Fee', value: { type: 'unknown' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkgRecommended: ComparisonPackage = {
|
|
||||||
id: 'recommended-premium',
|
|
||||||
name: 'Premium Cremation Service',
|
|
||||||
price: 8450,
|
|
||||||
provider: {
|
|
||||||
name: 'H. Parsons Funeral Directors',
|
|
||||||
location: 'Wentworth',
|
|
||||||
logoUrl: DEMO_LOGO,
|
|
||||||
rating: 4.9,
|
|
||||||
reviewCount: 203,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'Essentials',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Coffin',
|
|
||||||
info: 'Premium coffin allowance.',
|
|
||||||
value: { type: 'allowance', amount: 2500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cremation Certificate/Permit',
|
|
||||||
info: 'Medical referee fee.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Crematorium',
|
|
||||||
info: 'Premium crematorium.',
|
|
||||||
value: { type: 'price', amount: 850 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Death Registration Certificate',
|
|
||||||
info: 'NSW Registry.',
|
|
||||||
value: { type: 'price', amount: 70 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Dressing Fee',
|
|
||||||
info: 'Dressing and preparation.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'NSW Government Levy — Cremation',
|
|
||||||
info: 'Government levy.',
|
|
||||||
value: { type: 'price', amount: 45.1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Mortuary Care',
|
|
||||||
info: 'Full preparation and care.',
|
|
||||||
value: { type: 'price', amount: 580 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Professional Service Fee',
|
|
||||||
info: 'Full coordination.',
|
|
||||||
value: { type: 'price', amount: 4054.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transportation Service Fee',
|
|
||||||
info: 'Premium transfer.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Optionals',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
info: 'HD video recording.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
|
|
||||||
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Extras',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Allowance for Celebrant',
|
|
||||||
info: 'Premium celebrant.',
|
|
||||||
value: { type: 'allowance', amount: 700 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Catering',
|
|
||||||
info: 'Full catering included.',
|
|
||||||
value: { type: 'price', amount: 1200 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Newspaper Notice',
|
|
||||||
info: 'Published death notice.',
|
|
||||||
value: { type: 'price', amount: 350 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Saturday Service Fee',
|
|
||||||
info: 'No Saturday surcharge.',
|
|
||||||
value: { type: 'complimentary' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const defaultNav = (
|
|
||||||
<Navigation
|
|
||||||
logo={<FALogoNav />}
|
|
||||||
items={[
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
|
||||||
{ label: 'Log in', href: '/login' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComparisonPageV1> = {
|
|
||||||
title: 'Archive/ComparisonPage V1',
|
|
||||||
component: ComparisonPageV1,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
navigation: defaultNav,
|
|
||||||
onShare: () => alert('Share'),
|
|
||||||
onPrint: () => window.print(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComparisonPageV1>;
|
|
||||||
|
|
||||||
// --- Default (3 packages, desktop) -------------------------------------------
|
|
||||||
|
|
||||||
/** Three packages from different providers */
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Two Packages ------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Minimal two-package comparison */
|
|
||||||
export const TwoPackages: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay],
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- With Recommended --------------------------------------------------------
|
|
||||||
|
|
||||||
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
|
|
||||||
export const WithRecommended: Story = {
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
recommendedPackage: pkgRecommended,
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Mobile View -------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Mobile viewport — shows tabbed card view */
|
|
||||||
export const MobileView: Story = {
|
|
||||||
parameters: {
|
|
||||||
viewport: { defaultViewport: 'mobile1' },
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
packages: [pkgWollongong, pkgMackay, pkgInglewood],
|
|
||||||
recommendedPackage: pkgRecommended,
|
|
||||||
onArrange: (id) => alert(`Arrange: ${id}`),
|
|
||||||
onRemove: (id) => alert(`Remove: ${id}`),
|
|
||||||
onBack: () => alert('Back'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Interactive (with remove) -----------------------------------------------
|
|
||||||
|
|
||||||
/** Interactive — remove packages from comparison */
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: (args) => {
|
|
||||||
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComparisonPageV1
|
|
||||||
{...args}
|
|
||||||
packages={pkgs}
|
|
||||||
recommendedPackage={pkgRecommended}
|
|
||||||
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
|
|
||||||
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
|
|
||||||
onBack={() => alert('Back to packages')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import React, { useId, useState, useRef, useCallback } from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
|
||||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
|
||||||
import { Typography } from '../../atoms/Typography';
|
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
|
||||||
import { ComparisonTable, type ComparisonPackage } from '../../organisms/ComparisonTable';
|
|
||||||
import { ComparisonPackageCard } from '../../molecules/ComparisonPackageCard';
|
|
||||||
import { ComparisonTabCard } from '../../molecules/ComparisonTabCard';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Props for the ComparisonPageV1 */
|
|
||||||
export interface ComparisonPageV1Props {
|
|
||||||
/** User-selected packages to compare (max 3) */
|
|
||||||
packages: ComparisonPackage[];
|
|
||||||
/** System-recommended package — always shown as an additional column */
|
|
||||||
recommendedPackage?: ComparisonPackage;
|
|
||||||
/** Called when user clicks CTA on a package */
|
|
||||||
onArrange: (packageId: string) => void;
|
|
||||||
/** Called when user removes a package from comparison */
|
|
||||||
onRemove: (packageId: string) => void;
|
|
||||||
/** Called when user clicks Back */
|
|
||||||
onBack: () => void;
|
|
||||||
/** Called when user clicks Share */
|
|
||||||
onShare?: () => void;
|
|
||||||
/** Called when user clicks Print */
|
|
||||||
onPrint?: () => void;
|
|
||||||
/** Navigation bar slot */
|
|
||||||
navigation?: React.ReactNode;
|
|
||||||
/** MUI sx prop */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* **Archived — V1.** See `ComparisonPage.tsx` (V2) for the production version.
|
|
||||||
*
|
|
||||||
* Package comparison page for the FA design system.
|
|
||||||
*
|
|
||||||
* Desktop: Full ComparisonTable with info card, floating verified badges,
|
|
||||||
* section tables with left accent borders. Recommended package appears as the
|
|
||||||
* **last** column.
|
|
||||||
* Mobile: Tabbed card view with horizontal chip rail. Recommended package is
|
|
||||||
* the last tab.
|
|
||||||
*
|
|
||||||
* Share + Print utility actions in the page header.
|
|
||||||
*/
|
|
||||||
export const ComparisonPageV1 = React.forwardRef<HTMLDivElement, ComparisonPageV1Props>(
|
|
||||||
(
|
|
||||||
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const tablistId = useId();
|
|
||||||
const railRef = useRef<HTMLDivElement>(null);
|
|
||||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
|
|
||||||
const allPackages = React.useMemo(() => {
|
|
||||||
const result = [...packages];
|
|
||||||
if (recommendedPackage) {
|
|
||||||
result.push({ ...recommendedPackage, isRecommended: true });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [packages, recommendedPackage]);
|
|
||||||
|
|
||||||
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
|
||||||
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
|
|
||||||
|
|
||||||
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
|
|
||||||
const subtitle =
|
|
||||||
providerCount > 1
|
|
||||||
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
|
|
||||||
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
|
|
||||||
|
|
||||||
const hasRecommended = allPackages.some((p) => p.isRecommended);
|
|
||||||
|
|
||||||
const scrollToCenter = useCallback((idx: number) => {
|
|
||||||
const tab = tabRefs.current[idx];
|
|
||||||
if (tab && railRef.current) {
|
|
||||||
const rail = railRef.current;
|
|
||||||
const tabCenter = tab.offsetLeft + tab.offsetWidth / 2;
|
|
||||||
const railCenter = rail.offsetWidth / 2;
|
|
||||||
rail.scrollTo({ left: tabCenter - railCenter, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTabClick = useCallback(
|
|
||||||
(idx: number) => {
|
|
||||||
setActiveTabIdx(idx);
|
|
||||||
scrollToCenter(idx);
|
|
||||||
},
|
|
||||||
[scrollToCenter],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Center the default tab on mount
|
|
||||||
React.useEffect(() => {
|
|
||||||
const timer = setTimeout(() => scrollToCenter(0), 50);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} sx={sx}>
|
|
||||||
<WizardLayout
|
|
||||||
variant="wide-form"
|
|
||||||
navigation={navigation}
|
|
||||||
showBackLink
|
|
||||||
backLabel="Back"
|
|
||||||
onBack={onBack}
|
|
||||||
>
|
|
||||||
{/* Page header with Share/Print actions */}
|
|
||||||
<Box sx={{ mb: { xs: 3, md: 5 } }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
|
|
||||||
Compare packages
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" aria-live="polite">
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Share + Print */}
|
|
||||||
{(onShare || onPrint) && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
|
||||||
{onShare && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
startIcon={<ShareOutlinedIcon />}
|
|
||||||
onClick={onShare}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onPrint && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
startIcon={<PrintOutlinedIcon />}
|
|
||||||
onClick={onPrint}
|
|
||||||
>
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Desktop: ComparisonTable */}
|
|
||||||
{!isMobile && (
|
|
||||||
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile: Tab rail + card view */}
|
|
||||||
{isMobile && allPackages.length > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Tab rail — mini cards showing provider + package + price */}
|
|
||||||
<Box
|
|
||||||
ref={railRef}
|
|
||||||
role="tablist"
|
|
||||||
id={tablistId}
|
|
||||||
aria-label="Packages to compare"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 1.5,
|
|
||||||
overflowX: 'auto',
|
|
||||||
py: 2,
|
|
||||||
px: 2,
|
|
||||||
mx: -2,
|
|
||||||
mt: 1,
|
|
||||||
mb: 3,
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
'&::-webkit-scrollbar': { display: 'none' },
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allPackages.map((pkg, idx) => (
|
|
||||||
<ComparisonTabCard
|
|
||||||
key={pkg.id}
|
|
||||||
ref={(el: HTMLDivElement | null) => {
|
|
||||||
tabRefs.current[idx] = el;
|
|
||||||
}}
|
|
||||||
pkg={pkg}
|
|
||||||
isActive={idx === activeTabIdx}
|
|
||||||
hasRecommended={hasRecommended}
|
|
||||||
tabId={`comparison-tab-${idx}`}
|
|
||||||
tabPanelId={`comparison-tabpanel-${idx}`}
|
|
||||||
onClick={() => handleTabClick(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{activePackage && (
|
|
||||||
<Box
|
|
||||||
role="tabpanel"
|
|
||||||
id={`comparison-tabpanel-${activeTabIdx}`}
|
|
||||||
aria-labelledby={`comparison-tab-${activeTabIdx}`}
|
|
||||||
>
|
|
||||||
<ComparisonPackageCard pkg={activePackage} onArrange={onArrange} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</WizardLayout>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ComparisonPageV1.displayName = 'ComparisonPageV1';
|
|
||||||
export default ComparisonPageV1;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { ComparisonPage, default } from './ComparisonPage';
|
|
||||||
export type { ComparisonPageProps } from './ComparisonPage';
|
|
||||||
export { ComparisonPageV1 } from './ComparisonPageV1';
|
|
||||||
export type { ComparisonPageV1Props } from './ComparisonPageV1';
|
|
||||||
@@ -40,16 +40,6 @@ const nav = (
|
|||||||
<Navigation
|
<Navigation
|
||||||
logo={<FALogo />}
|
logo={<FALogo />}
|
||||||
items={[
|
items={[
|
||||||
{
|
|
||||||
label: 'Locations',
|
|
||||||
children: [
|
|
||||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
|
||||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
|
||||||
{ label: 'Sydney', href: '/locations/sydney' },
|
|
||||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
|
||||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
{ label: 'FAQ', href: '/faq' },
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
{ label: 'Log in', href: '/login' },
|
{ label: 'Log in', href: '/login' },
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
|||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||||
import { assetUrl } from '../../../utils/assetUrl';
|
|
||||||
import { Divider } from '../../atoms/Divider';
|
import { Divider } from '../../atoms/Divider';
|
||||||
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
|
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
|
||||||
|
|
||||||
@@ -186,8 +185,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
discoverMapSlot,
|
discoverMapSlot,
|
||||||
onSelectFeaturedProvider,
|
onSelectFeaturedProvider,
|
||||||
features = [],
|
features = [],
|
||||||
featuresHeading = '4 Reasons to use Funeral Arranger',
|
featuresHeading = 'How it works',
|
||||||
featuresBody,
|
featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
|
||||||
googleRating,
|
googleRating,
|
||||||
googleReviewCount,
|
googleReviewCount,
|
||||||
testimonials = [],
|
testimonials = [],
|
||||||
@@ -241,32 +240,21 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container
|
<Container
|
||||||
maxWidth={false}
|
maxWidth="md"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
maxWidth: 990,
|
pt: { xs: 8, md: 11 },
|
||||||
pt: { xs: 10, md: 14 },
|
pb: 4,
|
||||||
pb: { xs: 3, md: 4 },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="display3"
|
||||||
sx={{
|
|
||||||
color: 'rgba(255,255,255,0.85)',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Trusted by thousands of families across Australia
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="display2"
|
|
||||||
component="h1"
|
component="h1"
|
||||||
id="hero-heading"
|
id="hero-heading"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
sx={{ mb: 5, color: 'var(--fa-color-white)' }}
|
sx={{ mb: 3, color: 'var(--fa-color-white)' }}
|
||||||
>
|
>
|
||||||
{heroHeading}
|
{heroHeading}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -284,14 +272,20 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
px: { xs: 3, md: 2 },
|
px: 2,
|
||||||
pt: 6,
|
pt: 2,
|
||||||
pb: 0,
|
pb: 0,
|
||||||
mb: { xs: -14, md: -18 },
|
mb: { xs: -14, md: -18 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
|
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
|
||||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
{finderSlot || (
|
||||||
|
<FuneralFinderV3
|
||||||
|
heading="Find your local providers"
|
||||||
|
onSearch={onSearch}
|
||||||
|
loading={searchLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -321,7 +315,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="display2"
|
variant="display3"
|
||||||
component="h1"
|
component="h1"
|
||||||
id="hero-heading"
|
id="hero-heading"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -374,115 +368,28 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ maxWidth: 620, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 620, mx: 'auto' }}>
|
||||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
{finderSlot || (
|
||||||
|
<FuneralFinderV3
|
||||||
|
heading="Find your local providers"
|
||||||
|
onSearch={onSearch}
|
||||||
|
loading={searchLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
Section 2b: Partner Logos Carousel
|
Section 2c: Discover — Map + Featured Providers (V2)
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
|
||||||
{partnerLogos.length > 0 && (
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
aria-labelledby="partners-heading"
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
|
||||||
borderBottom: '1px solid #ebe0d4',
|
|
||||||
pt: { xs: 22, md: 28 },
|
|
||||||
pb: { xs: 10, md: 14 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container maxWidth="lg">
|
|
||||||
<Typography
|
|
||||||
variant="overline"
|
|
||||||
component="h2"
|
|
||||||
id="partners-heading"
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--fa-color-brand-600)',
|
|
||||||
mb: { xs: 6, md: 10 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{partnerTrustLine}
|
|
||||||
</Typography>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
{/* Carousel track */}
|
|
||||||
<Box
|
|
||||||
role="presentation"
|
|
||||||
sx={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
'&::before, &::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 80,
|
|
||||||
zIndex: 1,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&::before': {
|
|
||||||
left: 0,
|
|
||||||
background: 'linear-gradient(to right, #fff, transparent)',
|
|
||||||
},
|
|
||||||
'&::after': {
|
|
||||||
right: 0,
|
|
||||||
background: 'linear-gradient(to left, #fff, transparent)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
aria-label="Partner funeral directors"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: { xs: 8, md: 12 },
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 'max-content',
|
|
||||||
animation: 'logoScroll 35s linear infinite',
|
|
||||||
'@keyframes logoScroll': {
|
|
||||||
'0%': { transform: 'translateX(0)' },
|
|
||||||
'100%': { transform: 'translateX(-50%)' },
|
|
||||||
},
|
|
||||||
'&:hover': { animationPlayState: 'paused' },
|
|
||||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[...partnerLogos, ...partnerLogos].map((logo, i) => (
|
|
||||||
<Box
|
|
||||||
key={`${logo.alt}-${i}`}
|
|
||||||
component="img"
|
|
||||||
src={logo.src}
|
|
||||||
alt={i < partnerLogos.length ? logo.alt : ''}
|
|
||||||
aria-hidden={i >= partnerLogos.length ? true : undefined}
|
|
||||||
sx={{
|
|
||||||
height: { xs: 46, md: 55 },
|
|
||||||
maxWidth: { xs: 140, md: 184 },
|
|
||||||
width: 'auto',
|
|
||||||
objectFit: 'contain',
|
|
||||||
filter: 'grayscale(100%) brightness(1.2)',
|
|
||||||
opacity: 0.4,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
|
||||||
Section 2c: Discover — Map + Featured Providers
|
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════════ */}
|
||||||
{featuredProviders.length > 0 && (
|
{featuredProviders.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
aria-labelledby="discover-heading"
|
aria-labelledby="discover-heading"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: '#fdfbf9',
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
pt: { xs: 10, md: 14 },
|
pt: { xs: 22, md: 28 },
|
||||||
pb: { xs: 10, md: 14 },
|
pb: { xs: 8, md: 12 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
@@ -498,7 +405,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ maxWidth: 520, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
sx={{ maxWidth: 520, mx: 'auto' }}
|
||||||
>
|
>
|
||||||
From trusted local providers to personalised options, find the right care near
|
From trusted local providers to personalised options, find the right care near
|
||||||
you.
|
you.
|
||||||
@@ -571,7 +478,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||||
<Button variant="text" size="medium" onClick={onCtaClick}>
|
<Button variant="text" size="medium" onClick={onCtaClick}>
|
||||||
Start exploring
|
Start exploring →
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -579,212 +486,93 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
Section 3b: Why Use FA — Text + Image
|
Section 3: Partner Logos Carousel
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════════ */}
|
||||||
<Box
|
{partnerLogos.length > 0 && (
|
||||||
component="section"
|
<Box
|
||||||
aria-labelledby="why-fa-heading"
|
component="section"
|
||||||
sx={{
|
aria-label="Trusted partners"
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
sx={{
|
||||||
borderTop: '1px solid #f3efea',
|
bgcolor: 'var(--fa-color-surface-cool)',
|
||||||
borderBottom: '1px solid #f3efea',
|
pt: { xs: 10, md: 13 },
|
||||||
py: { xs: 10, md: 14 },
|
pb: { xs: 8, md: 10 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ textAlign: 'center', mb: { xs: 4, md: 6 } }}
|
||||||
|
>
|
||||||
|
{partnerTrustLine}
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Carousel track */}
|
||||||
<Box
|
<Box
|
||||||
|
role="presentation"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
overflow: 'hidden',
|
||||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
position: 'relative',
|
||||||
gap: { xs: 4, md: 8 },
|
'&::before, &::after': {
|
||||||
alignItems: 'center',
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 80,
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
left: 0,
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, var(--fa-color-surface-cool), transparent)',
|
||||||
|
},
|
||||||
|
'&::after': {
|
||||||
|
right: 0,
|
||||||
|
background:
|
||||||
|
'linear-gradient(to left, var(--fa-color-surface-cool), transparent)',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Text */}
|
|
||||||
<Box sx={{ textAlign: { xs: 'center', md: 'left' } }}>
|
|
||||||
<Typography
|
|
||||||
variant="overline"
|
|
||||||
component="div"
|
|
||||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
|
||||||
>
|
|
||||||
Why Use Funeral Arranger
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="display3"
|
|
||||||
component="h2"
|
|
||||||
id="why-fa-heading"
|
|
||||||
sx={{ mb: 2.5, color: 'text.primary' }}
|
|
||||||
>
|
|
||||||
Making an impossible time a little easier
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontSize: { xs: '0.875rem', md: '1rem' } }}
|
|
||||||
>
|
|
||||||
Funeral planning doesn’t have to be overwhelming. Whether a loved one has
|
|
||||||
just passed, is imminent, or you’re pre-planning the future for yourself.
|
|
||||||
Compare transparent pricing from local funeral directors. Explore the service
|
|
||||||
options, coffins and more to personalise a funeral plan in clear, easy steps.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Image */}
|
|
||||||
<Box
|
<Box
|
||||||
|
aria-label="Partner funeral directors"
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
display: 'flex',
|
||||||
overflow: 'hidden',
|
gap: { xs: 8, md: 12 },
|
||||||
'& img': {
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: 'max-content',
|
||||||
height: 'auto',
|
animation: 'logoScroll 35s linear infinite',
|
||||||
display: 'block',
|
'@keyframes logoScroll': {
|
||||||
|
'0%': { transform: 'translateX(0)' },
|
||||||
|
'100%': { transform: 'translateX(-50%)' },
|
||||||
},
|
},
|
||||||
|
'&:hover': { animationPlayState: 'paused' },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{[...partnerLogos, ...partnerLogos].map((logo, i) => (
|
||||||
src={assetUrl('/images/Homepage/people.png')}
|
<Box
|
||||||
alt="Family planning together with care and confidence"
|
key={`${logo.alt}-${i}`}
|
||||||
/>
|
component="img"
|
||||||
|
src={logo.src}
|
||||||
|
alt={i < partnerLogos.length ? logo.alt : ''}
|
||||||
|
aria-hidden={i >= partnerLogos.length ? true : undefined}
|
||||||
|
sx={{
|
||||||
|
height: { xs: 46, md: 55 },
|
||||||
|
maxWidth: { xs: 140, md: 184 },
|
||||||
|
width: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
filter: 'grayscale(100%) brightness(1.2)',
|
||||||
|
opacity: 0.4,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
|
||||||
Section 3c: What You Can Do Here — Three Feature Cards
|
|
||||||
═══════════════════════════════════════════════════════════════════ */}
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
aria-labelledby="what-you-can-do-heading"
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#f8f5f1',
|
|
||||||
py: { xs: 10, md: 14 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container maxWidth="lg">
|
|
||||||
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
|
|
||||||
<Typography
|
|
||||||
variant="overline"
|
|
||||||
component="div"
|
|
||||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
|
||||||
>
|
|
||||||
What You Can Do Here
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="display3"
|
|
||||||
component="h2"
|
|
||||||
id="what-you-can-do-heading"
|
|
||||||
sx={{ color: 'text.primary' }}
|
|
||||||
>
|
|
||||||
Three ways we can help you today
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' },
|
|
||||||
gap: { xs: 3, md: 4 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Card 1: Compare pricing */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
|
||||||
boxShadow: 'var(--fa-shadow-md)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 200,
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--fa-color-brand-100) 0%, var(--fa-color-brand-200) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
|
||||||
Compare pricing
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
|
||||||
See verified, itemised prices from multiple funeral directors in your area
|
|
||||||
side by side.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="outlined" size="medium" fullWidth>
|
|
||||||
Compare prices in my area
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Card 2: Find a funeral director */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
|
||||||
boxShadow: 'var(--fa-shadow-md)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 200,
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--fa-color-sage-100, #E8EDEF) 0%, var(--fa-color-sage-200, #D0D8DD) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
|
||||||
Find a funeral director
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
|
||||||
Browse rated, reviewed directors near you with profiles, photos, and contact
|
|
||||||
details.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="outlined" size="medium" fullWidth>
|
|
||||||
Search near me
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Card 3: Arrange a funeral */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
|
||||||
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
|
|
||||||
boxShadow: 'var(--fa-shadow-md)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 200,
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, var(--fa-color-neutral-100) 0%, var(--fa-color-neutral-200) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ p: 3, flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography variant="h5" component="h3" sx={{ mb: 1.5, color: 'text.primary' }}>
|
|
||||||
Arrange a funeral
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, flex: 1 }}>
|
|
||||||
Build a fully customised quote — choose coffin, flowers, transport,
|
|
||||||
venue, and more.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="outlined" size="medium" fullWidth>
|
|
||||||
Start building your quote
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
Section 4: Why Use Funeral Arranger (Features)
|
Section 4: Why Use Funeral Arranger (Features)
|
||||||
@@ -795,35 +583,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
aria-labelledby="features-heading"
|
aria-labelledby="features-heading"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
bgcolor: 'var(--fa-color-surface-default)',
|
||||||
py: { xs: 10, md: 14 },
|
py: { xs: 8, md: 12 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 5, md: 8 } }}>
|
||||||
<Typography
|
|
||||||
variant="overline"
|
|
||||||
component="div"
|
|
||||||
sx={{ color: 'var(--fa-color-brand-600)', mb: 1.5 }}
|
|
||||||
>
|
|
||||||
Why Use Funeral Arranger
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="display3"
|
variant="display3"
|
||||||
component="h2"
|
component="h2"
|
||||||
id="features-heading"
|
id="features-heading"
|
||||||
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }}
|
sx={{ mb: 2.5, color: 'text.primary' }}
|
||||||
>
|
>
|
||||||
{featuresHeading}
|
{featuresHeading}
|
||||||
</Typography>
|
</Typography>
|
||||||
{featuresBody && (
|
<Typography
|
||||||
<Typography
|
variant="body1"
|
||||||
variant="body1"
|
color="text.secondary"
|
||||||
color="text.secondary"
|
sx={{ maxWidth: 560, mx: 'auto' }}
|
||||||
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
>
|
||||||
>
|
{featuresBody}
|
||||||
{featuresBody}
|
</Typography>
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
@@ -869,22 +648,11 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
component="section"
|
component="section"
|
||||||
aria-labelledby="reviews-heading"
|
aria-labelledby="reviews-heading"
|
||||||
sx={{
|
sx={{
|
||||||
py: { xs: 10, md: 14 },
|
py: { xs: 8, md: 12 },
|
||||||
bgcolor: '#f8f5f1',
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="md">
|
<Container maxWidth="md">
|
||||||
<Typography
|
|
||||||
variant="overline"
|
|
||||||
component="div"
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--fa-color-brand-600)',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Funeral Arranger Reviews
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="display3"
|
variant="display3"
|
||||||
component="h2"
|
component="h2"
|
||||||
@@ -915,29 +683,26 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editorial testimonials — left-aligned with dividers */}
|
{/* Editorial testimonials — alternating alignment with dividers */}
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 0,
|
|
||||||
maxWidth: 560,
|
|
||||||
mx: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{testimonials.map((t, i) => {
|
{testimonials.map((t, i) => {
|
||||||
|
const isRight = i % 2 === 1;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`${t.name}-${i}`}>
|
<React.Fragment key={`${t.name}-${i}`}>
|
||||||
{i > 0 && <Divider sx={{ my: 4 }} />}
|
{i > 0 && <Divider sx={{ my: 4 }} />}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
textAlign: 'left',
|
textAlign: isRight ? 'right' : 'left',
|
||||||
|
maxWidth: '85%',
|
||||||
|
ml: isRight ? 'auto' : 0,
|
||||||
|
mr: isRight ? 0 : 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormatQuoteIcon
|
<FormatQuoteIcon
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
color: 'var(--fa-color-brand-300)',
|
color: 'var(--fa-color-brand-300)',
|
||||||
|
transform: isRight ? 'scaleX(-1)' : 'none',
|
||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -985,7 +750,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
sx={{
|
sx={{
|
||||||
background:
|
background:
|
||||||
'linear-gradient(180deg, var(--fa-color-brand-100, #F5EDE4) 0%, var(--fa-color-surface-warm, #FEF9F5) 100%)',
|
'linear-gradient(180deg, var(--fa-color-brand-100, #F5EDE4) 0%, var(--fa-color-surface-warm, #FEF9F5) 100%)',
|
||||||
py: { xs: 10, md: 14 },
|
py: { xs: 8, md: 10 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
||||||
@@ -997,7 +762,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
>
|
>
|
||||||
{ctaHeading}
|
{ctaHeading}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
<Button variant="text" size="large" onClick={onCtaClick}>
|
||||||
{ctaButtonLabel}
|
{ctaButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -1012,17 +777,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
aria-labelledby="faq-heading"
|
aria-labelledby="faq-heading"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'var(--fa-color-surface-default)',
|
bgcolor: 'var(--fa-color-surface-default)',
|
||||||
py: { xs: 10, md: 14 },
|
py: { xs: 8, md: 12 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Typography
|
<Typography
|
||||||
variant="display3"
|
variant="h2"
|
||||||
component="h2"
|
component="h2"
|
||||||
id="faq-heading"
|
id="faq-heading"
|
||||||
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
|
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
|
||||||
>
|
>
|
||||||
Frequently Asked Questions
|
FAQ
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ maxWidth: 700, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 700, mx: 'auto' }}>
|
||||||
@@ -1043,13 +808,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
|
||||||
<Typography
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: { xs: '0.875rem', md: '1rem' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.question}
|
{item.question}
|
||||||
</Typography>
|
</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
@@ -1064,11 +823,6 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
|||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
|
||||||
<Button variant="text" size="medium">
|
|
||||||
See more
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { HomePage } from './HomePage';
|
|||||||
import type { FeaturedProvider, TrustStat } from './HomePage';
|
import type { FeaturedProvider, TrustStat } from './HomePage';
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
import { Footer } from '../../organisms/Footer';
|
import { Footer } from '../../organisms/Footer';
|
||||||
import { assetUrl } from '../../../utils/assetUrl';
|
|
||||||
|
|
||||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,16 +41,6 @@ const nav = (
|
|||||||
<Navigation
|
<Navigation
|
||||||
logo={<FALogo />}
|
logo={<FALogo />}
|
||||||
items={[
|
items={[
|
||||||
{
|
|
||||||
label: 'Locations',
|
|
||||||
children: [
|
|
||||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
|
||||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
|
||||||
{ label: 'Sydney', href: '/locations/sydney' },
|
|
||||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
|
||||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
{ label: 'FAQ', href: '/faq' },
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
{ label: 'Log in', href: '/login' },
|
{ label: 'Log in', href: '/login' },
|
||||||
@@ -242,7 +231,7 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
navigation: nav,
|
navigation: nav,
|
||||||
footer,
|
footer,
|
||||||
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'),
|
heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
|
||||||
stats: trustStats,
|
stats: trustStats,
|
||||||
featuredProviders,
|
featuredProviders,
|
||||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { FeaturedProvider, TrustStat, PartnerLogo } from './HomePage';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
import { Footer } from '../../organisms/Footer';
|
import { Footer } from '../../organisms/Footer';
|
||||||
import { assetUrl } from '../../../utils/assetUrl';
|
|
||||||
|
|
||||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,16 +37,6 @@ const nav = (
|
|||||||
<Navigation
|
<Navigation
|
||||||
logo={<FALogo />}
|
logo={<FALogo />}
|
||||||
items={[
|
items={[
|
||||||
{
|
|
||||||
label: 'Locations',
|
|
||||||
children: [
|
|
||||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
|
||||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
|
||||||
{ label: 'Sydney', href: '/locations/sydney' },
|
|
||||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
|
||||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
{ label: 'FAQ', href: '/faq' },
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
{ label: 'Log in', href: '/login' },
|
{ label: 'Log in', href: '/login' },
|
||||||
@@ -188,8 +177,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'H.Parsons Funeral Directors',
|
name: 'H.Parsons Funeral Directors',
|
||||||
location: 'Wollongong, NSW',
|
location: 'Wollongong, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
startingPrice: 900,
|
startingPrice: 900,
|
||||||
@@ -199,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'Rankins Funerals',
|
name: 'Rankins Funerals',
|
||||||
location: 'Wollongong, NSW',
|
location: 'Wollongong, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
reviewCount: 23,
|
reviewCount: 23,
|
||||||
startingPrice: 1200,
|
startingPrice: 1200,
|
||||||
@@ -210,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'Easy Funerals',
|
name: 'Easy Funerals',
|
||||||
location: 'Sydney, NSW',
|
location: 'Sydney, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
reviewCount: 42,
|
reviewCount: 42,
|
||||||
startingPrice: 850,
|
startingPrice: 850,
|
||||||
@@ -220,30 +209,30 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
|
|
||||||
const partnerLogos: PartnerLogo[] = [
|
const partnerLogos: PartnerLogo[] = [
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||||
alt: 'H.Parsons Funeral Directors',
|
alt: 'H.Parsons Funeral Directors',
|
||||||
},
|
},
|
||||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||||
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
|
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||||
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
|
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||||
alt: 'Killick Family Funerals',
|
alt: 'Killick Family Funerals',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||||
alt: "Kenneally's Funerals",
|
alt: "Kenneally's Funerals",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||||
alt: 'Wollongong City Funerals',
|
alt: 'Wollongong City Funerals',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||||
alt: 'H.Parsons Shoalhaven',
|
alt: 'H.Parsons Shoalhaven',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||||
alt: 'Mackay Family Funerals',
|
alt: 'Mackay Family Funerals',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -251,7 +240,7 @@ const partnerLogos: PartnerLogo[] = [
|
|||||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const meta: Meta<typeof HomePage> = {
|
const meta: Meta<typeof HomePage> = {
|
||||||
title: 'Pages/HomePage',
|
title: 'Archive/HomePage V3',
|
||||||
component: HomePage,
|
component: HomePage,
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'fullscreen',
|
layout: 'fullscreen',
|
||||||
@@ -268,19 +257,19 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
navigation: nav,
|
navigation: nav,
|
||||||
footer,
|
footer,
|
||||||
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'),
|
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||||
heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
|
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||||
stats: trustStats,
|
stats: trustStats,
|
||||||
featuredProviders,
|
featuredProviders,
|
||||||
discoverMapSlot: React.createElement('img', {
|
discoverMapSlot: React.createElement('img', {
|
||||||
src: assetUrl('/images/placeholder/map.png'),
|
src: '/brandassets/images/placeholder/map.png',
|
||||||
alt: 'Map showing provider locations',
|
alt: 'Map showing provider locations',
|
||||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||||
}),
|
}),
|
||||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||||
partnerLogos,
|
partnerLogos,
|
||||||
partnerTrustLine: 'Verified funeral directors on Funeral Arranger',
|
partnerTrustLine: 'Trusted by hundreds of verified funeral directors across Australia',
|
||||||
features,
|
features,
|
||||||
googleRating: 4.9,
|
googleRating: 4.9,
|
||||||
googleReviewCount: 2340,
|
googleReviewCount: 2340,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { FuneralFinderV4 } from '../../organisms/FuneralFinder/FuneralFinderV4';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
import { Footer } from '../../organisms/Footer';
|
import { Footer } from '../../organisms/Footer';
|
||||||
import { assetUrl } from '../../../utils/assetUrl';
|
|
||||||
|
|
||||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,16 +38,6 @@ const nav = (
|
|||||||
<Navigation
|
<Navigation
|
||||||
logo={<FALogo />}
|
logo={<FALogo />}
|
||||||
items={[
|
items={[
|
||||||
{
|
|
||||||
label: 'Locations',
|
|
||||||
children: [
|
|
||||||
{ label: 'Melbourne', href: '/locations/melbourne' },
|
|
||||||
{ label: 'Brisbane', href: '/locations/brisbane' },
|
|
||||||
{ label: 'Sydney', href: '/locations/sydney' },
|
|
||||||
{ label: 'South Coast NSW', href: '/locations/south-coast-nsw' },
|
|
||||||
{ label: 'Central Coast NSW', href: '/locations/central-coast-nsw' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'FAQ', href: '/faq' },
|
{ label: 'FAQ', href: '/faq' },
|
||||||
{ label: 'Contact Us', href: '/contact' },
|
{ label: 'Contact Us', href: '/contact' },
|
||||||
{ label: 'Log in', href: '/login' },
|
{ label: 'Log in', href: '/login' },
|
||||||
@@ -189,8 +178,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'H.Parsons Funeral Directors',
|
name: 'H.Parsons Funeral Directors',
|
||||||
location: 'Wollongong, NSW',
|
location: 'Wollongong, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
startingPrice: 900,
|
startingPrice: 900,
|
||||||
@@ -200,8 +189,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'Rankins Funerals',
|
name: 'Rankins Funerals',
|
||||||
location: 'Wollongong, NSW',
|
location: 'Wollongong, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
reviewCount: 23,
|
reviewCount: 23,
|
||||||
startingPrice: 1200,
|
startingPrice: 1200,
|
||||||
@@ -211,8 +200,8 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
name: 'Easy Funerals',
|
name: 'Easy Funerals',
|
||||||
location: 'Sydney, NSW',
|
location: 'Sydney, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
reviewCount: 42,
|
reviewCount: 42,
|
||||||
startingPrice: 850,
|
startingPrice: 850,
|
||||||
@@ -221,30 +210,30 @@ const featuredProviders: FeaturedProvider[] = [
|
|||||||
|
|
||||||
const partnerLogos: PartnerLogo[] = [
|
const partnerLogos: PartnerLogo[] = [
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||||
alt: 'H.Parsons Funeral Directors',
|
alt: 'H.Parsons Funeral Directors',
|
||||||
},
|
},
|
||||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||||
{ src: assetUrl('/images/providers/easy-funerals/logo.png'), alt: 'Easy Funerals' },
|
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||||
{ src: assetUrl('/images/providers/lady-anne-funerals/logo.png'), alt: 'Lady Anne Funerals' },
|
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
src: '/brandassets/images/providers/killick-family-funerals/logo.png',
|
||||||
alt: 'Killick Family Funerals',
|
alt: 'Killick Family Funerals',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||||
alt: "Kenneally's Funerals",
|
alt: "Kenneally's Funerals",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||||
alt: 'Wollongong City Funerals',
|
alt: 'Wollongong City Funerals',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||||
alt: 'H.Parsons Shoalhaven',
|
alt: 'H.Parsons Shoalhaven',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||||
alt: 'Mackay Family Funerals',
|
alt: 'Mackay Family Funerals',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -269,7 +258,7 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
navigation: nav,
|
navigation: nav,
|
||||||
footer,
|
footer,
|
||||||
heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
|
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||||
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||||
finderSlot: React.createElement(FuneralFinderV4, {
|
finderSlot: React.createElement(FuneralFinderV4, {
|
||||||
@@ -278,7 +267,7 @@ export const Default: Story = {
|
|||||||
stats: trustStats,
|
stats: trustStats,
|
||||||
featuredProviders,
|
featuredProviders,
|
||||||
discoverMapSlot: React.createElement('img', {
|
discoverMapSlot: React.createElement('img', {
|
||||||
src: assetUrl('/images/placeholder/map.png'),
|
src: '/brandassets/images/placeholder/map.png',
|
||||||
alt: 'Map showing provider locations',
|
alt: 'Map showing provider locations',
|
||||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
|
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
|
||||||
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
|
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
|
||||||
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
|
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
|
||||||
--fa-map-pin-height: 28px; /** Pill height — compact for map density */
|
|
||||||
--fa-map-pin-font-size: 12px; /** Small but legible price text */
|
|
||||||
--fa-map-pin-dot-size: 12px; /** Small circle marker */
|
|
||||||
--fa-map-pin-nub-size: 6px; /** Nub triangle size */
|
|
||||||
--fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */
|
|
||||||
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
|
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
|
||||||
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
|
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
|
||||||
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
|
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
|
||||||
@@ -273,10 +268,6 @@
|
|||||||
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
|
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
|
||||||
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
|
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
|
||||||
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
|
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
|
||||||
--fa-map-pin-padding-x: var(--fa-spacing-3); /** 12px horizontal padding inside pill */
|
|
||||||
--fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */
|
|
||||||
--fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */
|
|
||||||
--fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */
|
|
||||||
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
|
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
|
||||||
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
|
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
|
||||||
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
|
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
|
||||||
|
|||||||
9
src/theme/generated/tokens.d.ts
vendored
9
src/theme/generated/tokens.d.ts
vendored
@@ -71,15 +71,6 @@ export declare const InputFontSizeDefault: string;
|
|||||||
export declare const InputBorderRadiusDefault: string;
|
export declare const InputBorderRadiusDefault: string;
|
||||||
export declare const InputGapDefault: string;
|
export declare const InputGapDefault: string;
|
||||||
export declare const InputIconSizeDefault: string;
|
export declare const InputIconSizeDefault: string;
|
||||||
export declare const MapPinHeight: string;
|
|
||||||
export declare const MapPinPaddingX: string;
|
|
||||||
export declare const MapPinFontSize: string;
|
|
||||||
export declare const MapPinBorderRadius: string;
|
|
||||||
export declare const MapPinDotSize: string;
|
|
||||||
export declare const MapPinNubSize: string;
|
|
||||||
export declare const MiniCardImageHeight: string;
|
|
||||||
export declare const MiniCardContentPadding: string;
|
|
||||||
export declare const MiniCardContentGap: string;
|
|
||||||
export declare const ProviderCardImageHeight: string;
|
export declare const ProviderCardImageHeight: string;
|
||||||
export declare const ProviderCardLogoSize: string;
|
export declare const ProviderCardLogoSize: string;
|
||||||
export declare const ProviderCardLogoBorderRadius: string;
|
export declare const ProviderCardLogoBorderRadius: string;
|
||||||
|
|||||||
@@ -72,15 +72,6 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
|
|||||||
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
|
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
|
||||||
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
|
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
|
||||||
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
|
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
|
||||||
export const MapPinHeight = "28px"; // Pill height — compact for map density
|
|
||||||
export const MapPinPaddingX = "12px"; // 12px horizontal padding inside pill
|
|
||||||
export const MapPinFontSize = "12px"; // Small but legible price text
|
|
||||||
export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape
|
|
||||||
export const MapPinDotSize = "12px"; // Small circle marker
|
|
||||||
export const MapPinNubSize = "6px"; // Nub triangle size
|
|
||||||
export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids
|
|
||||||
export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding
|
|
||||||
export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows
|
|
||||||
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
|
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
|
||||||
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
|
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
|
||||||
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius
|
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Resolves a static asset path. In local dev the path is served by Storybook's
|
|
||||||
* staticDirs; when STORYBOOK_ASSET_BASE is set (e.g. Chromatic builds) it
|
|
||||||
* prepends the external host URL so images load from Gitea.
|
|
||||||
*/
|
|
||||||
export const assetUrl = (path: string): string => {
|
|
||||||
const base =
|
|
||||||
typeof import.meta !== 'undefined' ? (import.meta.env?.STORYBOOK_ASSET_BASE ?? '') : '';
|
|
||||||
return `${base}${path}`;
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"mapPin": {
|
|
||||||
"$description": "MapPin atom tokens — price-pill map markers for provider/venue map views. Verified (brand) vs unverified (neutral) visual distinction.",
|
|
||||||
"height": { "$type": "dimension", "$value": "28px", "$description": "Pill height — compact for map density" },
|
|
||||||
"paddingX": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px horizontal padding inside pill" },
|
|
||||||
"fontSize": { "$type": "dimension", "$value": "12px", "$description": "Small but legible price text" },
|
|
||||||
"borderRadius": { "$type": "dimension", "$value": "{borderRadius.full}", "$description": "Fully rounded pill shape" },
|
|
||||||
"dot": {
|
|
||||||
"$description": "Dot variant for pins without a price label.",
|
|
||||||
"size": { "$type": "dimension", "$value": "12px", "$description": "Small circle marker" }
|
|
||||||
},
|
|
||||||
"nub": {
|
|
||||||
"$description": "Downward-pointing nub anchoring the pill to the map location.",
|
|
||||||
"size": { "$type": "dimension", "$value": "6px", "$description": "Nub triangle size" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"miniCard": {
|
|
||||||
"$description": "MiniCard molecule tokens — compact vertical card for providers, venues, packages in grids, recommendations, and map popups.",
|
|
||||||
"image": {
|
|
||||||
"$type": "dimension",
|
|
||||||
"$description": "Hero image area dimensions.",
|
|
||||||
"height": { "$value": "120px", "$description": "Shorter image than full listing cards (180px) for compact grids" }
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"$description": "Content area spacing.",
|
|
||||||
"padding": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px — matches ProviderCard/VenueCard content padding" },
|
|
||||||
"gap": { "$type": "dimension", "$value": "{spacing.1}", "$description": "4px vertical gap between content rows" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user