Compare commits
27 Commits
db9d1ba603
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f121ac7168 | |||
| 9ac8e31516 | |||
| 348f3912fd | |||
| 9e627d88a6 | |||
| ab25af2e67 | |||
| 5b2a41f4e4 | |||
| 5e93f3a0d0 | |||
| e89ac360e8 | |||
| b7a2a4e136 | |||
| cd0f79f2f5 | |||
| c3c0beadb9 | |||
| 52fd0f199a | |||
| eb26242ece | |||
| 723cdf908a | |||
| c457ee8b0d | |||
| 9f16bc87c2 | |||
| ec4b18152b | |||
| 86df44496f | |||
| 2b9aeaf8ef | |||
| 5364c1a3fc | |||
| ae1e344a8a | |||
| 4fecb81853 | |||
| f7efa7165c | |||
| 2843bf289f | |||
| abdbf56c87 | |||
| 9f5848b8a3 | |||
| 4af684ec8f |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -8,9 +8,28 @@ tokens/export/
|
||||
.DS_Store
|
||||
*.tgz
|
||||
|
||||
# Claude / Playwright artifacts
|
||||
# AI agent tooling
|
||||
.playwright-mcp/
|
||||
.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-storybook.log
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,203 +0,0 @@
|
||||
# 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
|
||||
164
src/components/atoms/MapPin/MapPin.stories.tsx
Normal file
164
src/components/atoms/MapPin/MapPin.stories.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
218
src/components/atoms/MapPin/MapPin.tsx
Normal file
218
src/components/atoms/MapPin/MapPin.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
2
src/components/atoms/MapPin/index.ts
Normal file
2
src/components/atoms/MapPin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MapPin, default } from './MapPin';
|
||||
export type { MapPinProps } from './MapPin';
|
||||
166
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal file
166
src/components/molecules/CompareBar/CompareBar.stories.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
};
|
||||
114
src/components/molecules/CompareBar/CompareBar.tsx
Normal file
114
src/components/molecules/CompareBar/CompareBar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
2
src/components/molecules/CompareBar/index.ts
Normal file
2
src/components/molecules/CompareBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CompareBar, default } from './CompareBar';
|
||||
export type { CompareBarProps, CompareBarPackage } from './CompareBar';
|
||||
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
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;
|
||||
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
2
src/components/molecules/ComparisonColumnCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonColumnCard, default } from './ComparisonColumnCard';
|
||||
export type { ComparisonColumnCardProps } from './ComparisonColumnCard';
|
||||
@@ -0,0 +1,163 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
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;
|
||||
2
src/components/molecules/ComparisonPackageCard/index.ts
Normal file
2
src/components/molecules/ComparisonPackageCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonPackageCard, default } from './ComparisonPackageCard';
|
||||
export type { ComparisonPackageCardProps } from './ComparisonPackageCard';
|
||||
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
),
|
||||
],
|
||||
};
|
||||
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
154
src/components/molecules/ComparisonTabCard/ComparisonTabCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
2
src/components/molecules/ComparisonTabCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ComparisonTabCard, default } from './ComparisonTabCard';
|
||||
export type { ComparisonTabCardProps } from './ComparisonTabCard';
|
||||
138
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal file
138
src/components/molecules/MapPopup/MapPopup.stories.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
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 />
|
||||
</>
|
||||
),
|
||||
};
|
||||
301
src/components/molecules/MapPopup/MapPopup.tsx
Normal file
301
src/components/molecules/MapPopup/MapPopup.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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;
|
||||
2
src/components/molecules/MapPopup/index.ts
Normal file
2
src/components/molecules/MapPopup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MapPopup, default } from './MapPopup';
|
||||
export type { MapPopupProps } from './MapPopup';
|
||||
166
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal file
166
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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={() => {}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
311
src/components/molecules/MiniCard/MiniCard.tsx
Normal file
311
src/components/molecules/MiniCard/MiniCard.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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;
|
||||
2
src/components/molecules/MiniCard/index.ts
Normal file
2
src/components/molecules/MiniCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MiniCard, default } from './MiniCard';
|
||||
export type { MiniCardProps, MiniCardBadge } from './MiniCard';
|
||||
@@ -0,0 +1,356 @@
|
||||
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}`),
|
||||
},
|
||||
};
|
||||
377
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
377
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
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;
|
||||
9
src/components/organisms/ComparisonTable/index.ts
Normal file
9
src/components/organisms/ComparisonTable/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ComparisonTable, default } from './ComparisonTable';
|
||||
export type {
|
||||
ComparisonTableProps,
|
||||
ComparisonPackage,
|
||||
ComparisonProvider,
|
||||
ComparisonSection,
|
||||
ComparisonLineItem,
|
||||
ComparisonCellValue,
|
||||
} from './ComparisonTable';
|
||||
@@ -41,10 +41,6 @@ export interface FuneralFinderV3Props {
|
||||
onSearch?: (params: FuneralFinderV3SearchParams) => void;
|
||||
/** Shows loading state on the CTA */
|
||||
loading?: boolean;
|
||||
/** Optional heading override */
|
||||
heading?: string;
|
||||
/** Optional subheading override */
|
||||
subheading?: string;
|
||||
/** MUI sx override for the root container */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
@@ -251,13 +247,7 @@ const selectMenuProps = {
|
||||
*/
|
||||
export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3Props>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
onSearch,
|
||||
loading = false,
|
||||
heading = 'Find funeral directors near you',
|
||||
subheading,
|
||||
sx,
|
||||
} = props;
|
||||
const { onSearch, loading = false, sx } = props;
|
||||
|
||||
// ─── IDs for aria-labelledby ──────────────────────────────
|
||||
const id = React.useId();
|
||||
@@ -392,29 +382,6 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
...(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 ─────────────────────────────────── */}
|
||||
<Box ref={statusSectionRef}>
|
||||
<SectionLabel id={statusLabelId}>How Can We Help</SectionLabel>
|
||||
@@ -561,7 +528,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
placeholder="Enter suburb or postcode"
|
||||
inputRef={locationInputRef}
|
||||
startAdornment={
|
||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||
<InputAdornment position="start" sx={{ ml: 0.25, mr: -0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 20,
|
||||
@@ -577,6 +544,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
...fieldBaseSx,
|
||||
'& .MuiOutlinedInput-input': {
|
||||
...fieldInputStyles,
|
||||
pl: 0.75,
|
||||
'&::placeholder': {
|
||||
color: 'var(--fa-color-text-disabled)',
|
||||
opacity: 1,
|
||||
@@ -617,12 +585,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
|
||||
loading={loading}
|
||||
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
|
||||
onClick={handleSubmit}
|
||||
sx={{ minHeight: 52 }}
|
||||
sx={{ minHeight: { xs: 40, sm: 52 }, fontSize: { xs: '0.875rem', sm: undefined } }}
|
||||
>
|
||||
Search Local Providers
|
||||
Search
|
||||
</Button>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
|
||||
>
|
||||
|
||||
@@ -143,3 +143,28 @@ export const ExtendedNavigation: Story = {
|
||||
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,9 +6,14 @@ import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
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 MenuIcon from '@mui/icons-material/Menu';
|
||||
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 { IconButton } from '../../atoms/IconButton';
|
||||
import { Link } from '../../atoms/Link';
|
||||
@@ -18,14 +23,16 @@ import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A navigation link item */
|
||||
/** A navigation link item. May have children to render as a dropdown. */
|
||||
export interface NavItem {
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** URL to navigate to */
|
||||
href: string;
|
||||
/** URL to navigate to (ignored when `children` is provided) */
|
||||
href?: string;
|
||||
/** Click handler (alternative to href for SPA navigation) */
|
||||
onClick?: () => void;
|
||||
/** Sub-items rendered as a dropdown (desktop) or collapsible (mobile) */
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
/** Props for the FA Navigation organism */
|
||||
@@ -44,6 +51,163 @@ export interface NavigationProps {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -51,26 +215,13 @@ export interface NavigationProps {
|
||||
*
|
||||
* Responsive header with logo, navigation links, and optional CTA.
|
||||
* 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"
|
||||
* (2391:41508) mobile patterns.
|
||||
*
|
||||
* 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')}
|
||||
* />
|
||||
* ```
|
||||
* Composes AppBar + Link + IconButton + Button + Divider + Drawer + Menu.
|
||||
*/
|
||||
export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
({ logo, onLogoClick, items = [], ctaLabel, onCtaClick, sx }, ref) => {
|
||||
@@ -78,6 +229,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('md'));
|
||||
|
||||
const handleDrawerToggle = () => setDrawerOpen((prev) => !prev);
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -147,24 +299,28 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
aria-label="Main navigation"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 3.5 }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
underline="hover"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<DesktopDropdown key={item.label} item={item} />
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
underline="hover"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-900)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
|
||||
{ctaLabel && (
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
@@ -210,36 +366,40 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
|
||||
{/* Nav items */}
|
||||
<List component="nav" aria-label="Main navigation">
|
||||
{items.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.label}
|
||||
component="a"
|
||||
href={item.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (item.onClick) {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
{items.map((item) =>
|
||||
item.children && item.children.length > 0 ? (
|
||||
<MobileCollapsible key={item.label} item={item} onItemClick={closeDrawer} />
|
||||
) : (
|
||||
<ListItemButton
|
||||
key={item.label}
|
||||
component="a"
|
||||
href={item.href}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (item.onClick) {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
closeDrawer();
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 3,
|
||||
minHeight: 44,
|
||||
'&:hover': {
|
||||
bgcolor: 'var(--fa-color-brand-100)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
|
||||
{ctaLabel && (
|
||||
@@ -250,7 +410,7 @@ export const Navigation = React.forwardRef<HTMLDivElement, NavigationProps>(
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (onCtaClick) onCtaClick();
|
||||
setDrawerOpen(false);
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
|
||||
@@ -14,98 +14,102 @@ const DEMO_IMAGE =
|
||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||
|
||||
const essentials = [
|
||||
{
|
||||
name: 'Accommodation',
|
||||
price: 1500,
|
||||
info: 'Refrigerated holding of the deceased prior to the funeral service.',
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
price: 1500,
|
||||
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
|
||||
},
|
||||
{
|
||||
name: 'Doctor Fee for Cremation',
|
||||
price: 1500,
|
||||
info: 'Statutory medical referee fee required for all cremations in NSW.',
|
||||
},
|
||||
{
|
||||
name: 'NSW Government Levy — Cremation',
|
||||
price: 1500,
|
||||
info: 'NSW Government cremation levy as set by the Department of Health.',
|
||||
},
|
||||
{
|
||||
name: 'Professional Mortuary Care',
|
||||
price: 1500,
|
||||
info: 'Preparation and care of the deceased.',
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
price: 1500,
|
||||
info: 'Coordination of all funeral arrangements and services.',
|
||||
},
|
||||
{
|
||||
name: 'Allowance for Coffin',
|
||||
price: 1500,
|
||||
price: 1750,
|
||||
isAllowance: true,
|
||||
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: 'Cremation Certificate/Permit',
|
||||
price: 350,
|
||||
info: 'Statutory medical referee fee required for all cremations in NSW.',
|
||||
},
|
||||
{
|
||||
name: 'Allowance for Hearse',
|
||||
price: 1500,
|
||||
isAllowance: true,
|
||||
info: 'Allowance for hearse transfer — distance surcharges may apply.',
|
||||
name: 'Crematorium: Mackay Family Crematorium',
|
||||
price: 660,
|
||||
info: 'Cremation facility fees at the selected crematorium.',
|
||||
},
|
||||
{
|
||||
name: 'Death Registration Certificate',
|
||||
price: 70,
|
||||
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
|
||||
},
|
||||
{
|
||||
name: 'Dressing Fee',
|
||||
price: 0,
|
||||
priceLabel: 'Complimentary',
|
||||
info: 'Dressing and preparation of the deceased — included at no charge.',
|
||||
},
|
||||
{
|
||||
name: 'NSW Government Levy — Cremation',
|
||||
price: 45.1,
|
||||
info: 'NSW Government cremation levy as set by the Department of Health.',
|
||||
},
|
||||
{
|
||||
name: 'Professional Mortuary Care',
|
||||
price: 440,
|
||||
info: 'Preparation and care of the deceased.',
|
||||
},
|
||||
{
|
||||
name: 'Professional Service Fee',
|
||||
price: 3650.9,
|
||||
info: 'Coordination of all funeral arrangements and services.',
|
||||
},
|
||||
{
|
||||
name: 'Transportation Service Fee',
|
||||
price: 0,
|
||||
priceLabel: 'Complimentary',
|
||||
info: 'Transfer of the deceased to the funeral home — included in this package.',
|
||||
},
|
||||
];
|
||||
|
||||
const complimentary = [
|
||||
const optionals = [
|
||||
{
|
||||
name: 'Dressing Fee',
|
||||
info: 'Dressing and preparation of the deceased — included at no charge.',
|
||||
name: 'Digital Recording of the Funeral Service',
|
||||
priceLabel: 'Complimentary',
|
||||
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 = {
|
||||
heading: 'Extras',
|
||||
items: [
|
||||
{
|
||||
name: 'Allowance for Flowers',
|
||||
price: 1500,
|
||||
isAllowance: true,
|
||||
info: 'Seasonal floral arrangements for the service.',
|
||||
},
|
||||
{
|
||||
name: 'Allowance for Master of Ceremonies',
|
||||
price: 1500,
|
||||
name: 'Allowance for Celebrant',
|
||||
price: 550,
|
||||
isAllowance: true,
|
||||
info: 'Professional celebrant or MC for the funeral service.',
|
||||
},
|
||||
{
|
||||
name: 'After Business Hours Service Surcharge',
|
||||
price: 1500,
|
||||
info: 'Additional fee for services held outside standard business hours.',
|
||||
name: 'Catering',
|
||||
priceLabel: 'Price On Application',
|
||||
info: 'Catering for the wake or post-service gathering.',
|
||||
},
|
||||
{
|
||||
name: 'After Hours Prayers',
|
||||
price: 1500,
|
||||
info: 'Evening prayer service at the funeral home.',
|
||||
name: 'Newspaper Notice',
|
||||
priceLabel: 'Price On Application',
|
||||
info: 'Published death notice in local or national newspaper.',
|
||||
},
|
||||
{
|
||||
name: 'Coffin Bearing by Funeral Directors',
|
||||
price: 1500,
|
||||
info: 'Professional pallbearing by funeral directors.',
|
||||
},
|
||||
{
|
||||
name: 'Digital Recording',
|
||||
price: 1500,
|
||||
info: 'Professional video recording of the funeral service.',
|
||||
name: 'Saturday Service Fee',
|
||||
price: 880,
|
||||
info: 'Additional fee for services held on a Saturday.',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -169,16 +173,16 @@ type Story = StoryObj<typeof PackageDetail>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Full package detail panel — Essentials, Complimentary, Total, then Extras */
|
||||
/** Full package detail panel — Essentials, Optionals, Total, then Extras */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 900,
|
||||
name: 'Traditional Family Cremation Service',
|
||||
price: 6966,
|
||||
sections: [
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
{ heading: 'Optionals', items: optionals },
|
||||
],
|
||||
total: 2700,
|
||||
total: 6966,
|
||||
extras,
|
||||
terms: termsText,
|
||||
onArrange: () => alert('Make Arrangement clicked'),
|
||||
@@ -191,10 +195,10 @@ export const Default: Story = {
|
||||
/** Compare button in loading state — adding to comparison cart */
|
||||
export const CompareLoading: Story = {
|
||||
args: {
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 900,
|
||||
name: 'Traditional Family Cremation Service',
|
||||
price: 6966,
|
||||
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
||||
total: 6000,
|
||||
total: 6966,
|
||||
onArrange: () => alert('Make Arrangement'),
|
||||
onCompare: () => {},
|
||||
compareLoading: true,
|
||||
@@ -203,16 +207,16 @@ export const CompareLoading: Story = {
|
||||
|
||||
// --- Without Extras ----------------------------------------------------------
|
||||
|
||||
/** Simpler package with essentials and complimentary only */
|
||||
/** Simpler package with essentials and optionals only — no extras */
|
||||
export const WithoutExtras: Story = {
|
||||
args: {
|
||||
name: 'Essential Funeral Package',
|
||||
price: 600,
|
||||
name: 'Essential Cremation Package',
|
||||
price: 4850,
|
||||
sections: [
|
||||
{ heading: 'Essentials', items: essentials.slice(0, 6) },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
{ heading: 'Optionals', items: optionals.slice(0, 2) },
|
||||
],
|
||||
total: 9000,
|
||||
total: 4850,
|
||||
terms: termsText,
|
||||
onArrange: () => alert('Make Arrangement'),
|
||||
onCompare: () => alert('Compare'),
|
||||
@@ -332,9 +336,9 @@ export const PackageSelectPage: Story = {
|
||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||
sections={[
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
{ heading: 'Optionals', items: optionals },
|
||||
]}
|
||||
total={2700}
|
||||
total={6966}
|
||||
extras={extras}
|
||||
terms={termsText}
|
||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface PackageLineItem {
|
||||
price?: number;
|
||||
/** Whether this is an allowance (shows asterisk) */
|
||||
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") */
|
||||
@@ -83,6 +85,7 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
|
||||
info={item.info}
|
||||
price={item.price}
|
||||
isAllowance={item.isAllowance}
|
||||
priceLabel={item.priceLabel}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -109,6 +109,7 @@ export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorP
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label={heading}
|
||||
aria-required={continueLabel ? true : undefined}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
|
||||
457
src/components/pages/ComparisonPage/ComparisonPage.stories.tsx
Normal file
457
src/components/pages/ComparisonPage/ComparisonPage.stories.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
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')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
237
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
237
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
457
src/components/pages/ComparisonPage/ComparisonPageV1.stories.tsx
Normal file
457
src/components/pages/ComparisonPage/ComparisonPageV1.stories.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
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')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
230
src/components/pages/ComparisonPage/ComparisonPageV1.tsx
Normal file
230
src/components/pages/ComparisonPage/ComparisonPageV1.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
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;
|
||||
4
src/components/pages/ComparisonPage/index.ts
Normal file
4
src/components/pages/ComparisonPage/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ComparisonPage, default } from './ComparisonPage';
|
||||
export type { ComparisonPageProps } from './ComparisonPage';
|
||||
export { ComparisonPageV1 } from './ComparisonPageV1';
|
||||
export type { ComparisonPageV1Props } from './ComparisonPageV1';
|
||||
@@ -40,6 +40,16 @@ const nav = (
|
||||
<Navigation
|
||||
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' },
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { FuneralFinderV3, type FuneralFinderV3SearchParams } from '../../organisms/FuneralFinder';
|
||||
|
||||
@@ -185,8 +186,8 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
discoverMapSlot,
|
||||
onSelectFeaturedProvider,
|
||||
features = [],
|
||||
featuresHeading = 'How it works',
|
||||
featuresBody = 'Search local funeral directors, compare transparent pricing, and personalise a plan — all in your own time. No pressure, no hidden costs.',
|
||||
featuresHeading = '4 Reasons to use Funeral Arranger',
|
||||
featuresBody,
|
||||
googleRating,
|
||||
googleReviewCount,
|
||||
testimonials = [],
|
||||
@@ -240,21 +241,32 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
maxWidth={false}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
textAlign: 'center',
|
||||
pt: { xs: 8, md: 11 },
|
||||
pb: 4,
|
||||
maxWidth: 990,
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 3, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="display3"
|
||||
variant="body1"
|
||||
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"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
sx={{ mb: 3, color: 'var(--fa-color-white)' }}
|
||||
sx={{ mb: 5, color: 'var(--fa-color-white)' }}
|
||||
>
|
||||
{heroHeading}
|
||||
</Typography>
|
||||
@@ -272,20 +284,14 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
pt: 2,
|
||||
px: { xs: 3, md: 2 },
|
||||
pt: 6,
|
||||
pb: 0,
|
||||
mb: { xs: -14, md: -18 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
|
||||
{finderSlot || (
|
||||
<FuneralFinderV3
|
||||
heading="Find your local providers"
|
||||
onSearch={onSearch}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
)}
|
||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -315,7 +321,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="display3"
|
||||
variant="display2"
|
||||
component="h1"
|
||||
id="hero-heading"
|
||||
tabIndex={-1}
|
||||
@@ -368,28 +374,115 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: 620, mx: 'auto' }}>
|
||||
{finderSlot || (
|
||||
<FuneralFinderV3
|
||||
heading="Find your local providers"
|
||||
onSearch={onSearch}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
)}
|
||||
{finderSlot || <FuneralFinderV3 onSearch={onSearch} loading={searchLoading} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 2c: Discover — Map + Featured Providers (V2)
|
||||
Section 2b: Partner Logos Carousel
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{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 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="discover-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
pt: { xs: 22, md: 28 },
|
||||
pb: { xs: 8, md: 12 },
|
||||
bgcolor: '#fdfbf9',
|
||||
pt: { xs: 10, md: 14 },
|
||||
pb: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
@@ -405,7 +498,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 520, mx: 'auto' }}
|
||||
sx={{ maxWidth: 520, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
>
|
||||
From trusted local providers to personalised options, find the right care near
|
||||
you.
|
||||
@@ -478,7 +571,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
{/* CTA */}
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Button variant="text" size="medium" onClick={onCtaClick}>
|
||||
Start exploring →
|
||||
Start exploring
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
@@ -486,93 +579,212 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
Section 3: Partner Logos Carousel
|
||||
Section 3b: Why Use FA — Text + Image
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Trusted partners"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-cool)',
|
||||
pt: { xs: 10, md: 13 },
|
||||
pb: { xs: 8, md: 10 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mb: { xs: 4, md: 6 } }}
|
||||
>
|
||||
{partnerTrustLine}
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
{/* Carousel track */}
|
||||
<Box
|
||||
component="section"
|
||||
aria-labelledby="why-fa-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
borderTop: '1px solid #f3efea',
|
||||
borderBottom: '1px solid #f3efea',
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<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, var(--fa-color-surface-cool), transparent)',
|
||||
},
|
||||
'&::after': {
|
||||
right: 0,
|
||||
background:
|
||||
'linear-gradient(to left, var(--fa-color-surface-cool), transparent)',
|
||||
},
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: { xs: 4, md: 8 },
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 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
|
||||
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%)' },
|
||||
borderRadius: 'var(--fa-border-radius-lg, 12px)',
|
||||
overflow: 'hidden',
|
||||
'& img': {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
},
|
||||
'&: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,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
src={assetUrl('/images/Homepage/people.png')}
|
||||
alt="Family planning together with care and confidence"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</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)
|
||||
@@ -583,26 +795,35 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="features-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 8, md: 12 },
|
||||
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 }}
|
||||
>
|
||||
Why Use Funeral Arranger
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="features-heading"
|
||||
sx={{ mb: 2.5, color: 'text.primary' }}
|
||||
sx={{ mb: featuresBody ? 2.5 : 0, color: 'text.primary' }}
|
||||
>
|
||||
{featuresHeading}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 560, mx: 'auto' }}
|
||||
>
|
||||
{featuresBody}
|
||||
</Typography>
|
||||
{featuresBody && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: 560, mx: 'auto', fontSize: { xs: '0.875rem', md: '1rem' } }}
|
||||
>
|
||||
{featuresBody}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -648,11 +869,22 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
component="section"
|
||||
aria-labelledby="reviews-heading"
|
||||
sx={{
|
||||
py: { xs: 8, md: 12 },
|
||||
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||
py: { xs: 10, md: 14 },
|
||||
bgcolor: '#f8f5f1',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
variant="display3"
|
||||
component="h2"
|
||||
@@ -683,26 +915,29 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Editorial testimonials — alternating alignment with dividers */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Editorial testimonials — left-aligned with dividers */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
maxWidth: 560,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{testimonials.map((t, i) => {
|
||||
const isRight = i % 2 === 1;
|
||||
return (
|
||||
<React.Fragment key={`${t.name}-${i}`}>
|
||||
{i > 0 && <Divider sx={{ my: 4 }} />}
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: isRight ? 'right' : 'left',
|
||||
maxWidth: '85%',
|
||||
ml: isRight ? 'auto' : 0,
|
||||
mr: isRight ? 0 : 'auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<FormatQuoteIcon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: 'var(--fa-color-brand-300)',
|
||||
transform: isRight ? 'scaleX(-1)' : 'none',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -750,7 +985,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--fa-color-brand-100, #F5EDE4) 0%, var(--fa-color-surface-warm, #FEF9F5) 100%)',
|
||||
py: { xs: 8, md: 10 },
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
||||
@@ -762,7 +997,7 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
>
|
||||
{ctaHeading}
|
||||
</Typography>
|
||||
<Button variant="text" size="large" onClick={onCtaClick}>
|
||||
<Button variant="contained" size="medium" onClick={onCtaClick}>
|
||||
{ctaButtonLabel}
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -777,17 +1012,17 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
aria-labelledby="faq-heading"
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-default)',
|
||||
py: { xs: 8, md: 12 },
|
||||
py: { xs: 10, md: 14 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="h2"
|
||||
variant="display3"
|
||||
component="h2"
|
||||
id="faq-heading"
|
||||
sx={{ textAlign: 'center', mb: { xs: 5, md: 8 }, color: 'text.primary' }}
|
||||
>
|
||||
FAQ
|
||||
Frequently Asked Questions
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ maxWidth: 700, mx: 'auto' }}>
|
||||
@@ -808,7 +1043,13 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0, py: 1.5 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: { xs: '0.875rem', md: '1rem' },
|
||||
}}
|
||||
>
|
||||
{item.question}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
@@ -823,6 +1064,11 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
<Box sx={{ textAlign: 'center', mt: 4 }}>
|
||||
<Button variant="text" size="medium">
|
||||
See more
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HomePage } from './HomePage';
|
||||
import type { FeaturedProvider, TrustStat } from './HomePage';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +42,16 @@ const nav = (
|
||||
<Navigation
|
||||
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' },
|
||||
@@ -231,7 +242,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: '/brandassets/images/heroes/parsonshero.png',
|
||||
heroImageUrl: assetUrl('/images/heroes/parsonshero.png'),
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { FeaturedProvider, TrustStat, PartnerLogo } from './HomePage';
|
||||
import React from 'react';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,6 +38,16 @@ const nav = (
|
||||
<Navigation
|
||||
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' },
|
||||
@@ -177,8 +188,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -188,8 +199,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -199,8 +210,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -209,30 +220,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
||||
{ src: assetUrl('/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/killick-family-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -240,7 +251,7 @@ const partnerLogos: PartnerLogo[] = [
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof HomePage> = {
|
||||
title: 'Archive/HomePage V3',
|
||||
title: 'Pages/HomePage',
|
||||
component: HomePage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
@@ -257,19 +268,19 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-couple.jpg'),
|
||||
heroHeading: 'Compare funeral director pricing near you and arrange with confidence',
|
||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: '/brandassets/images/placeholder/map.png',
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
alt: 'Map showing provider locations',
|
||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
}),
|
||||
onSelectFeaturedProvider: (id) => console.log('Featured provider:', id),
|
||||
partnerLogos,
|
||||
partnerTrustLine: 'Trusted by hundreds of verified funeral directors across Australia',
|
||||
partnerTrustLine: 'Verified funeral directors on Funeral Arranger',
|
||||
features,
|
||||
googleRating: 4.9,
|
||||
googleReviewCount: 2340,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FuneralFinderV4 } from '../../organisms/FuneralFinder/FuneralFinderV4';
|
||||
import React from 'react';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import { Footer } from '../../organisms/Footer';
|
||||
import { assetUrl } from '../../../utils/assetUrl';
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,16 @@ const nav = (
|
||||
<Navigation
|
||||
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' },
|
||||
@@ -178,8 +189,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/hparsons-funeral-home-kiama/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-kiama/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
startingPrice: 900,
|
||||
@@ -189,8 +200,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Rankins Funerals',
|
||||
location: 'Wollongong, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/rankins-funerals/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||
rating: 4.8,
|
||||
reviewCount: 23,
|
||||
startingPrice: 1200,
|
||||
@@ -200,8 +211,8 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
name: 'Easy Funerals',
|
||||
location: 'Sydney, NSW',
|
||||
verified: true,
|
||||
imageUrl: '/brandassets/images/venues/lakeside-memorial-park-chapel/01.jpg',
|
||||
logoUrl: '/brandassets/images/providers/easy-funerals/logo.png',
|
||||
imageUrl: assetUrl('/images/venues/lakeside-memorial-park-chapel/01.jpg'),
|
||||
logoUrl: assetUrl('/images/providers/easy-funerals/logo.png'),
|
||||
rating: 4.5,
|
||||
reviewCount: 42,
|
||||
startingPrice: 850,
|
||||
@@ -210,30 +221,30 @@ const featuredProviders: FeaturedProvider[] = [
|
||||
|
||||
const partnerLogos: PartnerLogo[] = [
|
||||
{
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors/logo.png',
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||
alt: 'H.Parsons Funeral Directors',
|
||||
},
|
||||
{ src: '/brandassets/images/providers/rankins-funerals/logo.png', alt: 'Rankins Funerals' },
|
||||
{ src: '/brandassets/images/providers/easy-funerals/logo.png', alt: 'Easy Funerals' },
|
||||
{ src: '/brandassets/images/providers/lady-anne-funerals/logo.png', alt: 'Lady Anne Funerals' },
|
||||
{ src: assetUrl('/images/providers/rankins-funerals/logo.png'), alt: 'Rankins Funerals' },
|
||||
{ src: assetUrl('/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/killick-family-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||
alt: 'Killick Family Funerals',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/kenneallys-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/kenneallys-funerals/logo.png'),
|
||||
alt: "Kenneally's Funerals",
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/wollongong-city-funerals/logo.png',
|
||||
src: assetUrl('/images/providers/wollongong-city-funerals/logo.png'),
|
||||
alt: 'Wollongong City Funerals',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/hparsons-funeral-directors-shoalhaven/logo.png',
|
||||
src: assetUrl('/images/providers/hparsons-funeral-directors-shoalhaven/logo.png'),
|
||||
alt: 'H.Parsons Shoalhaven',
|
||||
},
|
||||
{
|
||||
src: '/brandassets/images/providers/mackay-family-funerals/logo.webp',
|
||||
src: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||
alt: 'Mackay Family Funerals',
|
||||
},
|
||||
];
|
||||
@@ -258,7 +269,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
navigation: nav,
|
||||
footer,
|
||||
heroImageUrl: '/brandassets/images/heroes/hero-3.png',
|
||||
heroImageUrl: assetUrl('/images/heroes/hero-3.png'),
|
||||
heroHeading: 'Compare funeral directors pricing near you and arrange with confidence',
|
||||
heroSubheading: 'Transparent pricing \u00B7 No hidden fees \u00B7 Arrange 24/7',
|
||||
finderSlot: React.createElement(FuneralFinderV4, {
|
||||
@@ -267,7 +278,7 @@ export const Default: Story = {
|
||||
stats: trustStats,
|
||||
featuredProviders,
|
||||
discoverMapSlot: React.createElement('img', {
|
||||
src: '/brandassets/images/placeholder/map.png',
|
||||
src: assetUrl('/images/placeholder/map.png'),
|
||||
alt: 'Map showing provider locations',
|
||||
style: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
--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-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-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 */
|
||||
@@ -268,6 +273,10 @@
|
||||
--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-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-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 */
|
||||
|
||||
9
src/theme/generated/tokens.d.ts
vendored
9
src/theme/generated/tokens.d.ts
vendored
@@ -71,6 +71,15 @@ export declare const InputFontSizeDefault: string;
|
||||
export declare const InputBorderRadiusDefault: string;
|
||||
export declare const InputGapDefault: 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 ProviderCardLogoSize: string;
|
||||
export declare const ProviderCardLogoBorderRadius: string;
|
||||
|
||||
@@ -72,6 +72,15 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
|
||||
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 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 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
|
||||
|
||||
10
src/utils/assetUrl.ts
Normal file
10
src/utils/assetUrl.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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}`;
|
||||
};
|
||||
17
tokens/component/mapPin.json
Normal file
17
tokens/component/mapPin.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tokens/component/miniCard.json
Normal file
15
tokens/component/miniCard.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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