Compare commits
16 Commits
db9d1ba603
...
52fd0f199a
| Author | SHA1 | Date | |
|---|---|---|---|
| 52fd0f199a | |||
| eb26242ece | |||
| 723cdf908a | |||
| c457ee8b0d | |||
| 9f16bc87c2 | |||
| ec4b18152b | |||
| 86df44496f | |||
| 2b9aeaf8ef | |||
| 5364c1a3fc | |||
| ae1e344a8a | |||
| 4fecb81853 | |||
| f7efa7165c | |||
| 2843bf289f | |||
| abdbf56c87 | |||
| 9f5848b8a3 | |||
| 4af684ec8f |
51
.agent/workflows/fa-build-component.md
Normal file
51
.agent/workflows/fa-build-component.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
description: Scaffold and build a new component following FA conventions and the full component lifecycle.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Build Component
|
||||||
|
|
||||||
|
Create a new component following the FA design system conventions and the
|
||||||
|
component lifecycle defined in `docs/reference/component-lifecycle.md`.
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
Provide the tier and component name (e.g., `atom Button`, `molecule PriceCard`).
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
1. Read `docs/memory/component-registry.md` — confirm component is planned, not already in-progress
|
||||||
|
2. **Dependency check (molecules):** all constituent atoms must be `done`
|
||||||
|
3. **Dependency check (organisms):** all constituent molecules and atoms must be `done`
|
||||||
|
4. Read `docs/conventions/component-conventions.md` — follow the template
|
||||||
|
5. Read `docs/design-system.md` — understand brand context for this component
|
||||||
|
6. Read `docs/memory/token-registry.md` — know available tokens
|
||||||
|
|
||||||
|
If any dependency is not met, STOP and report what's missing.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
1. Create folder: `src/components/{tier}/{ComponentName}/`
|
||||||
|
2. Create `ComponentName.tsx`:
|
||||||
|
- Extend appropriate MUI base component props
|
||||||
|
- All visual values from theme (never hardcode)
|
||||||
|
- `React.forwardRef` for interactive elements
|
||||||
|
- Forward `sx` prop
|
||||||
|
- JSDoc on every prop
|
||||||
|
- `displayName` set
|
||||||
|
3. Create `ComponentName.stories.tsx`:
|
||||||
|
- `tags: ['autodocs']` in meta
|
||||||
|
- Stories: Default, AllVariants, AllSizes, Disabled, Loading, Error, LongContent, MinimalContent
|
||||||
|
4. Create `index.ts` barrel export
|
||||||
|
5. Create component tokens in `tokens/component/{component}.json` if needed
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
- Open story in Storybook at `localhost:6006`
|
||||||
|
- Confirm all stories render without errors
|
||||||
|
- Check visual appearance matches design system
|
||||||
|
|
||||||
|
## After
|
||||||
|
|
||||||
|
- Update `docs/memory/component-registry.md` — mark as `review`
|
||||||
|
- Update `docs/memory/token-registry.md` if tokens were created
|
||||||
|
- Update `docs/memory/decisions-log.md` with any design decisions
|
||||||
91
.agent/workflows/fa-page-review.md
Normal file
91
.agent/workflows/fa-page-review.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
description: Review and tweak an existing page — visual QA, spacing adjustments, copy refinement, responsive check. Current phase focus.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Page Review
|
||||||
|
|
||||||
|
Review an existing page component for visual quality, consistency, and polish.
|
||||||
|
This is the primary workflow for the current project phase (tweaking and
|
||||||
|
finalising pages rather than building from scratch).
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
Provide the page name (e.g., `ProvidersStep`, `HomePage`, `CoffinsStep`).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Gather context
|
||||||
|
|
||||||
|
- Read the page source: `src/components/pages/{PageName}/{PageName}.tsx`
|
||||||
|
- Read its stories: `src/components/pages/{PageName}/{PageName}.stories.tsx`
|
||||||
|
- Check `docs/memory/session-log.md` for recent changes to this page
|
||||||
|
- Check `docs/memory/decisions-log.md` for relevant design decisions
|
||||||
|
|
||||||
|
### 2. Open in Storybook
|
||||||
|
|
||||||
|
Navigate to `localhost:6006` and open the page's default story.
|
||||||
|
|
||||||
|
### 3. Review checklist
|
||||||
|
|
||||||
|
At each breakpoint (375px mobile, 768px tablet, 1280px desktop):
|
||||||
|
|
||||||
|
**Visual hierarchy**
|
||||||
|
- Heading sizes follow a clear progression (display → h1 → h2 → body)
|
||||||
|
- Primary action is visually prominent
|
||||||
|
- Whitespace groups related content, separates unrelated
|
||||||
|
|
||||||
|
**Spacing**
|
||||||
|
- All spacing uses `theme.spacing()` — no magic numbers
|
||||||
|
- Consistent gaps between sections (typically spacing(4) or spacing(6))
|
||||||
|
- No visual crowding; no excessive empty space
|
||||||
|
|
||||||
|
**Typography**
|
||||||
|
- Body text line length between 45-75 characters
|
||||||
|
- Line heights readable (1.5 for body, tighter for headings)
|
||||||
|
- Font weights create clear distinction between labels and content
|
||||||
|
|
||||||
|
**Colour**
|
||||||
|
- All colours from theme/tokens — no raw hex in source
|
||||||
|
- Contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
|
||||||
|
- Error states use copper (#B0610F), not red
|
||||||
|
|
||||||
|
**Responsive**
|
||||||
|
- Content reflows cleanly between breakpoints
|
||||||
|
- No horizontal overflow or content clipping
|
||||||
|
- Touch targets at least 44px on mobile
|
||||||
|
- Images and cards scale proportionally
|
||||||
|
|
||||||
|
**Copy**
|
||||||
|
- Warm but not gushy — understated empathy
|
||||||
|
- Labels are clear, unambiguous, sentence case
|
||||||
|
- CTAs use action verbs ("Continue", "Select", "Save")
|
||||||
|
- No jargon or funeral-industry terms without context
|
||||||
|
|
||||||
|
**Interactive states**
|
||||||
|
- Hover, focus, disabled visible on all controls
|
||||||
|
- Focus indicators: 2px solid, 2px offset, visible on `:focus-visible`
|
||||||
|
- Disabled: reduced opacity, no pointer events
|
||||||
|
|
||||||
|
**Edge cases**
|
||||||
|
- Long content handled (truncation, scroll, or wrap)
|
||||||
|
- Empty/minimal content doesn't break layout
|
||||||
|
- Loading states present where data is async
|
||||||
|
|
||||||
|
### 4. Make targeted fixes
|
||||||
|
|
||||||
|
For issues found:
|
||||||
|
- Make small, focused changes (spacing, copy, alignment)
|
||||||
|
- Do NOT restructure the page — this is polish, not redesign
|
||||||
|
- Reference design decisions from decisions-log.md when relevant
|
||||||
|
|
||||||
|
### 5. Before/after comparison
|
||||||
|
|
||||||
|
- Screenshot the page at each breakpoint BEFORE and AFTER changes
|
||||||
|
- Present both to the user for approval
|
||||||
|
|
||||||
|
### 6. Report
|
||||||
|
|
||||||
|
Summarise:
|
||||||
|
- Changes made (with file and line references)
|
||||||
|
- Remaining issues (with severity P0/P1/P2)
|
||||||
|
- Suggested next steps (e.g., "run fa-preflight before committing")
|
||||||
52
.agent/workflows/fa-preflight.md
Normal file
52
.agent/workflows/fa-preflight.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description: Pre-commit quality checks — TypeScript, ESLint, Prettier, Storybook build, token sync, hardcoded values. // turbo
|
||||||
|
---
|
||||||
|
|
||||||
|
# Preflight Checks
|
||||||
|
|
||||||
|
Run all quality gates before committing. Report PASS/FAIL/WARN for each.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
1. **TypeScript compilation**
|
||||||
|
Run `npx tsc --noEmit`
|
||||||
|
FAIL if any type errors. Report file and line for each error.
|
||||||
|
|
||||||
|
2. **ESLint**
|
||||||
|
Run `npm run lint`
|
||||||
|
FAIL if any errors (warnings are WARN). Report count and top issues.
|
||||||
|
|
||||||
|
3. **Prettier**
|
||||||
|
Run `npm run format:check`
|
||||||
|
FAIL if any files need formatting. List files.
|
||||||
|
|
||||||
|
4. **Storybook build**
|
||||||
|
Run `npx storybook build --quiet`
|
||||||
|
FAIL if build errors. Report the failing component.
|
||||||
|
|
||||||
|
5. **Token sync**
|
||||||
|
Compare timestamps: are any `tokens/**/*.json` files newer than `src/theme/generated/`?
|
||||||
|
WARN if generated files are stale. Fix: `npm run build:tokens`
|
||||||
|
|
||||||
|
6. **Hardcoded values scan**
|
||||||
|
Search `src/components/**/*.tsx` (excluding `*.stories.tsx`) for hex colour patterns (`#[0-9a-fA-F]{3,8}`).
|
||||||
|
WARN for each match not on a line containing `// ok-hardcode`.
|
||||||
|
|
||||||
|
7. **Component exports**
|
||||||
|
For each component folder in `src/components/`, verify `index.ts` exists and re-exports the component.
|
||||||
|
FAIL if any component is missing its barrel export.
|
||||||
|
|
||||||
|
## Report format
|
||||||
|
|
||||||
|
| Check | Status | Details |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| TypeScript | PASS/FAIL | error count |
|
||||||
|
| ESLint | PASS/FAIL/WARN | error/warning count |
|
||||||
|
| Prettier | PASS/FAIL | files needing format |
|
||||||
|
| Storybook | PASS/FAIL | failing component |
|
||||||
|
| Token sync | PASS/WARN | stale files |
|
||||||
|
| Hardcoded values | PASS/WARN | matches found |
|
||||||
|
| Exports | PASS/FAIL | missing index.ts |
|
||||||
|
|
||||||
|
If all PASS: safe to commit.
|
||||||
|
If any FAIL: fix before committing.
|
||||||
37
.agent/workflows/fa-session-start.md
Normal file
37
.agent/workflows/fa-session-start.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Start a work session — read memory files, check project status, report what needs attention.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session Start
|
||||||
|
|
||||||
|
Read project state and report a summary before beginning work.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Read recent activity**
|
||||||
|
- Read `docs/memory/session-log.md` — summarise the last 2-3 sessions
|
||||||
|
- Note any incomplete work or open questions
|
||||||
|
|
||||||
|
2. **Check component status**
|
||||||
|
- Read `docs/memory/component-registry.md`
|
||||||
|
- Count components by status: done, in-progress, planned, needs-revision
|
||||||
|
- Flag any components stuck in "in-progress" or "needs-revision"
|
||||||
|
|
||||||
|
3. **Check token status**
|
||||||
|
- Read `docs/memory/token-registry.md`
|
||||||
|
- Note any gaps or recently added tokens
|
||||||
|
|
||||||
|
4. **Check pending reviews**
|
||||||
|
- Read `docs/reference/retroactive-review-plan.md`
|
||||||
|
- List any tiers or components that haven't been reviewed yet
|
||||||
|
|
||||||
|
5. **Check recent decisions**
|
||||||
|
- Read `docs/memory/decisions-log.md`
|
||||||
|
- Note the 3 most recent decisions and their status
|
||||||
|
|
||||||
|
6. **Report**
|
||||||
|
Present a brief summary:
|
||||||
|
- Recent activity (1-2 sentences)
|
||||||
|
- Component stats (done/in-progress/planned)
|
||||||
|
- Pending review items
|
||||||
|
- Suggested next actions
|
||||||
28
.agent/workflows/fa-token-sync.md
Normal file
28
.agent/workflows/fa-token-sync.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
description: Rebuild CSS and JS outputs from token JSON sources. // turbo
|
||||||
|
---
|
||||||
|
|
||||||
|
# Token Sync
|
||||||
|
|
||||||
|
Validate token JSON and regenerate all outputs.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Validate token files**
|
||||||
|
Read each file in `tokens/primitives/`, `tokens/semantic/`, `tokens/component/`.
|
||||||
|
Every token object must have `$value`, `$type`, and `$description`.
|
||||||
|
Report any tokens missing required fields.
|
||||||
|
|
||||||
|
2. **Build tokens**
|
||||||
|
Run `npm run build:tokens`
|
||||||
|
Report success or failure with error details.
|
||||||
|
|
||||||
|
3. **Verify outputs**
|
||||||
|
Confirm these generated files exist and are non-empty:
|
||||||
|
- `src/theme/generated/tokens.css`
|
||||||
|
- `src/theme/generated/tokens.js`
|
||||||
|
Report any missing or empty outputs.
|
||||||
|
|
||||||
|
4. **Check theme integration**
|
||||||
|
Read `src/theme/index.ts` — are any newly added tokens missing from the MUI theme mapping?
|
||||||
|
Report tokens that exist in generated output but aren't consumed by the theme.
|
||||||
40
.agent/workflows/fa-visual-qa.md
Normal file
40
.agent/workflows/fa-visual-qa.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Browser-based visual QA of a component or page in Storybook. Checks spacing, alignment, states, responsive behaviour.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Visual QA
|
||||||
|
|
||||||
|
Open a component or page story in Storybook and perform a visual quality check
|
||||||
|
at multiple breakpoints.
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
Provide the component name or story path (e.g., `ProvidersStep`, `Button`, `Pages/HomePage`).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Ensure Storybook is running** at `localhost:6006`
|
||||||
|
|
||||||
|
2. **Navigate to the story**
|
||||||
|
Construct URL: `http://localhost:6006/?path=/story/{tier}-{componentname}--default`
|
||||||
|
If the component is a page: `pages-{pagename}--default`
|
||||||
|
|
||||||
|
3. **Check at three breakpoints**
|
||||||
|
For each of mobile (375px), tablet (768px), desktop (1280px):
|
||||||
|
|
||||||
|
- **Spacing:** consistent use of the spacing scale, no visual crowding or excessive gaps
|
||||||
|
- **Alignment:** elements aligned to grid, no off-by-one shifts
|
||||||
|
- **Typography:** hierarchy readable, line lengths between 45-75 characters, weights distinct
|
||||||
|
- **Colour:** all values appear to come from the design system (warm golds, neutral greys, copper accents)
|
||||||
|
- **Interactive states** (if applicable): hover, focus, disabled look correct
|
||||||
|
- **Overflow:** no content clipping, horizontal scroll, or text truncation issues
|
||||||
|
- **Touch targets:** buttons and interactive elements appear at least 44px at mobile
|
||||||
|
|
||||||
|
4. **Screenshot each breakpoint**
|
||||||
|
Capture and present to user for review.
|
||||||
|
|
||||||
|
5. **Report findings**
|
||||||
|
List issues found with severity:
|
||||||
|
- **P0:** Broken layout, missing content, inaccessible controls
|
||||||
|
- **P1:** Spacing inconsistency, contrast failure, overflow at one breakpoint
|
||||||
|
- **P2:** Minor alignment, typography weight, polish items
|
||||||
90
AGENTS.md
Normal file
90
AGENTS.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# FA 2.0 Design System
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Funeral Arranger (funeralarranger.com.au) design system rebuild. Client: Parsons
|
||||||
|
(H.Parsons Funeral Directors). Users are often in distress — the design language
|
||||||
|
must be warm, professional, trustworthy, and calm.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- React 18 + TypeScript
|
||||||
|
- Material UI (MUI) v5
|
||||||
|
- Storybook 8+ with autodocs
|
||||||
|
- Style Dictionary for token transformation
|
||||||
|
- W3C DTCG token format (2025.10 stable spec)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Source of truth: token JSON files in `tokens/` (DTCG format).
|
||||||
|
Flow: Token JSON → Style Dictionary → MUI theme + CSS vars → React components → Storybook
|
||||||
|
|
||||||
|
Token tiers: primitives (`tokens/primitives/`) → semantic (`tokens/semantic/`) → component (`tokens/component/`)
|
||||||
|
Component tiers: atoms → molecules → organisms → templates → pages (in `src/components/`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run build:tokens` — regenerate CSS/JS from token JSON (run after any token change)
|
||||||
|
- `npm run storybook` — dev server on localhost:6006
|
||||||
|
- `npm run type-check` — TypeScript compilation check
|
||||||
|
- `npm run lint` / `npm run lint:fix` — ESLint
|
||||||
|
- `npm run format` / `npm run format:check` — Prettier
|
||||||
|
- `npm run test` — Vitest
|
||||||
|
|
||||||
|
## Rules (always)
|
||||||
|
|
||||||
|
1. Consume the MUI theme for all visual values — `theme.palette.*`, `theme.spacing()`, `theme.typography.*`
|
||||||
|
2. Every token has `$value`, `$type`, and `$description` (DTCG format)
|
||||||
|
3. Error styling uses copper (#B0610F) not red — grief-sensitive context
|
||||||
|
4. Read `docs/memory/` before starting work (decisions-log, component-registry, token-registry, session-log)
|
||||||
|
5. Update `docs/memory/` after completing work
|
||||||
|
6. Run `npm run build:tokens` after any token JSON change
|
||||||
|
7. Verify in Storybook before marking any component done
|
||||||
|
8. Follow the component lifecycle in `docs/reference/component-lifecycle.md`
|
||||||
|
9. Commit and push after each coherent unit of work
|
||||||
|
|
||||||
|
## Rules (never)
|
||||||
|
|
||||||
|
- Use raw hex values in component source files — map to semantic tokens
|
||||||
|
- Mix token access methods — semantic via `theme.*`, component via `var(--fa-*)`
|
||||||
|
- Skip the barrel export (`index.ts`) for any component
|
||||||
|
- Edit files owned by another agent without coordinating (see File ownership)
|
||||||
|
|
||||||
|
## Git workflow
|
||||||
|
|
||||||
|
Remote: Gitea at `http://192.168.50.211:3000/richie/ParsonsFA.git`
|
||||||
|
Branch: all work on `main` for now.
|
||||||
|
|
||||||
|
Stage specific files (not `git add -A`). Commit message format:
|
||||||
|
```
|
||||||
|
Short summary (imperative mood, <70 chars)
|
||||||
|
|
||||||
|
- Bullet points with detail if needed
|
||||||
|
- Reference decision IDs (D001, D002...) when relevant
|
||||||
|
```
|
||||||
|
|
||||||
|
## File conventions
|
||||||
|
|
||||||
|
- Component folders: PascalCase — `Button/`, `PriceCard/`
|
||||||
|
- Token files: camelCase — `colours.json`, `typography.json`
|
||||||
|
- Each component folder: `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts`
|
||||||
|
- CSS custom properties prefix: `--fa-`
|
||||||
|
- Semantic tokens via theme, component tokens via CSS vars — see `docs/conventions/token-conventions.md`
|
||||||
|
- Component structure and props: see `docs/conventions/component-conventions.md`
|
||||||
|
|
||||||
|
## Key references
|
||||||
|
|
||||||
|
- Design spec: `docs/design-system.md`
|
||||||
|
- Component lifecycle: `docs/reference/component-lifecycle.md`
|
||||||
|
- Flow architecture (wizard steps, branching, fields): `documentation/flow-definition.yaml`
|
||||||
|
- Redesigned flow spec: `documentation/steps/flow-definition.yaml` + `documentation/steps/flow-spec.md`
|
||||||
|
- Cross-tool workflow: `docs/reference/cross-tool-workflow.md`
|
||||||
|
|
||||||
|
## File ownership
|
||||||
|
|
||||||
|
One file, one owner at a time. Check `docs/memory/session-log.md` before starting.
|
||||||
|
|
||||||
|
- **Component source, tokens, theme, memory files:** Claude Code owns writes
|
||||||
|
- **Visual QA, browser verification, screenshots:** Antigravity owns
|
||||||
|
- **Shared read:** all documentation, all source files, AGENTS.md
|
||||||
|
- **When backend agent starts:** backend owns `src/payload/`, `src/api/`; frontend agents never touch those. `documentation/flow-definition.yaml` is the shared contract.
|
||||||
124
CLAUDE.md
124
CLAUDE.md
@@ -1,113 +1,45 @@
|
|||||||
# FA 2.0 Design System
|
# Claude Code — FA 2.0 Design System
|
||||||
|
|
||||||
## Project overview
|
Follow all rules in `AGENTS.md`. This file contains Claude-specific behaviour only.
|
||||||
|
|
||||||
Rebuilding the Funeral Arranger (funeralarranger.com.au) design system using a
|
## Session startup (automatic)
|
||||||
code-first approach. Parsons (H.Parsons Funeral Directors) is the client. FA is
|
|
||||||
an Australian funeral planning platform — the design language must be warm,
|
|
||||||
professional, trustworthy, and calm. Users are often in distress.
|
|
||||||
|
|
||||||
## Tech stack
|
At the start of every session, automatically:
|
||||||
|
1. Read `docs/memory/session-log.md`, `decisions-log.md`, `component-registry.md`
|
||||||
|
2. Check `docs/reference/retroactive-review-plan.md` for pending review tiers
|
||||||
|
3. If reviews remain, run the next step (~30-60 min) before the user's main request
|
||||||
|
4. Report findings, then shift to whatever the user wants to work on
|
||||||
|
|
||||||
- React 18 + TypeScript
|
This is "morning housekeeping" — the user has approved this and expects it.
|
||||||
- Material UI (MUI) v5
|
|
||||||
- Storybook 8+ with autodocs
|
|
||||||
- Style Dictionary for token transformation
|
|
||||||
- W3C DTCG token format (2025.10 stable spec)
|
|
||||||
- Chromatic for Storybook hosting (later)
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**Source of truth:** Token JSON files in `tokens/` (DTCG format)
|
|
||||||
**Flow:** Token JSON → Style Dictionary → MUI theme + CSS vars → React components → Storybook
|
|
||||||
|
|
||||||
### Token tiers
|
|
||||||
1. **Primitives** (`tokens/primitives/`): Raw values — hex, px, font names, scales
|
|
||||||
2. **Semantic** (`tokens/semantic/`): Design intent — `color.text.primary`, `color.surface.default`
|
|
||||||
3. **Component** (`tokens/component/`): Per-component — `button.background.default`
|
|
||||||
|
|
||||||
### Component tiers (atomic design)
|
|
||||||
1. **Atoms** (`src/components/atoms/`): Button, Input, Typography, Badge, Icon, Avatar, Divider, Chip, Card, Link
|
|
||||||
2. **Molecules** (`src/components/molecules/`): SearchBar, PriceCard, ServiceOption, FormField
|
|
||||||
3. **Organisms** (`src/components/organisms/`): ServiceSelector, PricingTable, ArrangementForm, Navigation
|
|
||||||
|
|
||||||
## Critical rules
|
|
||||||
|
|
||||||
1. **Every component MUST consume the MUI theme** — never hardcode colours, spacing, typography, or shadows
|
|
||||||
2. **Every token MUST have a `$description`** — this is how agents maintain context about design intent
|
|
||||||
3. **Always read docs/design-system.md** before creating or modifying anything
|
|
||||||
4. **Always check docs/memory/** before starting work — these files contain decisions and state from previous sessions
|
|
||||||
5. **Always update docs/memory/** after completing work — log what was done, decisions made, and open questions
|
|
||||||
6. **Run `npm run build:tokens`** after any token JSON change
|
|
||||||
7. **Verify in Storybook** before marking any component done
|
|
||||||
8. **Follow the component lifecycle** — see `docs/reference/component-lifecycle.md` for the full quality gate sequence (build → QA → polish → present → iterate → normalize → preflight → commit)
|
|
||||||
9. **Commit and push after completing each unit of work** — see Git workflow below
|
|
||||||
|
|
||||||
## Git workflow
|
|
||||||
|
|
||||||
**Remote:** Gitea at `http://192.168.50.211:3000/richie/ParsonsFA.git` (credentials stored via git credential helper)
|
|
||||||
|
|
||||||
**After completing each unit of work** (a component, a token change, a bug fix, etc.):
|
|
||||||
1. Stage the changed files (`git add` — prefer naming specific files over `git add -A`)
|
|
||||||
2. Commit with a clear message describing what was done
|
|
||||||
3. Push to origin (`git push`)
|
|
||||||
|
|
||||||
This is **not optional** — the user relies on the git history for rollback safety. Each commit should represent a coherent, working state (Storybook builds, TypeScript compiles). Do not batch multiple unrelated changes into a single commit.
|
|
||||||
|
|
||||||
**Commit message format:**
|
|
||||||
```
|
|
||||||
Short summary (imperative mood, <70 chars)
|
|
||||||
|
|
||||||
- Bullet points with detail if needed
|
|
||||||
- Reference decision IDs (D001, D002...) when relevant
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Branch strategy:** All work on `main` for now. Feature branches when the project grows.
|
|
||||||
|
|
||||||
## Memory system
|
## Memory system
|
||||||
|
|
||||||
This project uses structured markdown files for cross-session memory.
|
Before starting any work, read:
|
||||||
|
- `docs/memory/decisions-log.md` — all design decisions with rationale
|
||||||
|
- `docs/memory/component-registry.md` — status of every component
|
||||||
|
- `docs/memory/token-registry.md` — all tokens with values and usage notes
|
||||||
|
- `docs/memory/session-log.md` — recent sessions; older in `docs/memory/archive/`
|
||||||
|
|
||||||
**Before starting any work, read these files:**
|
After completing work, update the relevant memory files and `session-log.md`.
|
||||||
- `docs/memory/decisions-log.md` — All design decisions with rationale
|
|
||||||
- `docs/memory/component-registry.md` — Status of every component (planned/in-progress/done)
|
|
||||||
- `docs/memory/token-registry.md` — All tokens with their current values and usage notes
|
|
||||||
- `docs/memory/session-log.md` — Recent sessions (last 2-3); older sessions in `docs/memory/archive/`
|
|
||||||
|
|
||||||
**Session startup — proactive review pass (do this automatically):**
|
|
||||||
Check `docs/reference/retroactive-review-plan.md` for the current review state. If
|
|
||||||
there are tiers or components that haven't been reviewed yet, run the next review
|
|
||||||
step (typically `/normalize {tier}` or `/audit {component}`) at the start of the
|
|
||||||
session before the user's main request. Keep this to ~30-60 min, then report
|
|
||||||
findings and shift to whatever the user wants to work on. This is "morning
|
|
||||||
housekeeping" — the user has approved this and expects it to happen without asking.
|
|
||||||
|
|
||||||
**After completing work, update:**
|
|
||||||
- The relevant memory files with what changed
|
|
||||||
- `docs/memory/session-log.md` with a summary of what was accomplished and next steps
|
|
||||||
|
|
||||||
## MCP servers
|
## MCP servers
|
||||||
|
|
||||||
- **Figma remote MCP** (`figma-remote-mcp`): Read FA 1.0 designs, extract design context
|
- **Figma remote MCP** — read FA 1.0 designs, extract design context
|
||||||
- **Storybook MCP** (`storybook`): Query component library for available components and props
|
- **Storybook MCP** — query component library for available components and props
|
||||||
|
|
||||||
Setup instructions in `docs/reference/mcp-setup.md`.
|
Setup instructions: `docs/reference/mcp-setup.md`
|
||||||
|
|
||||||
## File conventions
|
## Commit co-author
|
||||||
|
|
||||||
- Component folders: PascalCase (`Button/`, `PriceCard/`)
|
Append to every commit message:
|
||||||
- Token files: camelCase (`colours.json`, `typography.json`)
|
```
|
||||||
- Each component folder contains: `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts`
|
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||||
- CSS custom properties prefix: `--fa-` (e.g., `--fa-color-brand-primary`)
|
```
|
||||||
- MUI theme paths: follow MUI conventions (`palette.primary.main`)
|
|
||||||
|
|
||||||
## Naming conventions for tokens
|
## Cross-tool workflow
|
||||||
|
|
||||||
See `docs/conventions/token-conventions.md` for the full specification.
|
This project uses both Claude Code and Google Antigravity.
|
||||||
|
See `docs/reference/cross-tool-workflow.md` for task routing and coordination.
|
||||||
|
|
||||||
Quick reference:
|
Claude Code owns: component building, token creation, code quality, memory updates, git.
|
||||||
- Primitives: `color.blue.500`, `spacing.4`, `fontSize.base`
|
Antigravity owns: visual QA, browser-based verification, page-level review screenshots.
|
||||||
- Semantic: `color.text.primary`, `color.surface.default`, `color.interactive.default`
|
|
||||||
- Component: `button.background.default`, `button.background.hover`
|
|
||||||
|
|||||||
72
GEMINI.md
72
GEMINI.md
@@ -1,58 +1,26 @@
|
|||||||
# FA 2.0 Design System — Antigravity Rules
|
# Antigravity — FA 2.0 Design System
|
||||||
|
|
||||||
## Project context
|
Follow all rules in `AGENTS.md`. This file contains Antigravity-specific behaviour only.
|
||||||
|
|
||||||
Funeral Arranger (funeralarranger.com.au) design system rebuild. Parsons is the
|
## Visual verification
|
||||||
client. The design language must be warm, professional, trustworthy, and calm.
|
|
||||||
Users are often in distress.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- React 18 + TypeScript
|
|
||||||
- Material UI (MUI) v5
|
|
||||||
- Storybook 8+ (running on localhost:6006)
|
|
||||||
- Style Dictionary for token transformation
|
|
||||||
- W3C DTCG token format
|
|
||||||
|
|
||||||
## Hard rules
|
|
||||||
|
|
||||||
1. **Never hardcode colours, spacing, typography, or shadows** — always consume the MUI theme (`theme.palette.*`, `theme.spacing()`, `theme.typography.*`) or CSS custom properties (`var(--fa-*)`)
|
|
||||||
2. **Never use raw hex values in components** — map to semantic tokens
|
|
||||||
3. **Follow atomic design tiers:** atoms → molecules → organisms → templates → pages
|
|
||||||
4. **Component folders:** PascalCase, each contains `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts`
|
|
||||||
5. **Token files:** camelCase in `tokens/` directory (primitives → semantic → component tiers)
|
|
||||||
6. **CSS custom properties prefix:** `--fa-`
|
|
||||||
7. **Error styling uses copper (#B0610F)** not red — this is intentional for grief-sensitive context
|
|
||||||
8. **Copy tone:** warm but not gushy. Understated empathy. Let UI structure be the empathy.
|
|
||||||
|
|
||||||
## Token access convention
|
|
||||||
|
|
||||||
- Semantic tokens: `theme.palette.*`, `theme.typography.*`, `theme.spacing()`
|
|
||||||
- Component tokens: `var(--fa-component-property-state)`
|
|
||||||
- Never mix — semantic via theme, component via CSS vars
|
|
||||||
|
|
||||||
## Storybook
|
|
||||||
|
|
||||||
Dev server runs on `localhost:6006`. When verifying components visually, navigate
|
|
||||||
to the specific story URL. Story IDs follow the pattern:
|
|
||||||
`/story/{tier}-{componentname}--{variantname}`
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/components/atoms/ — Button, Input, Typography, Card, etc.
|
|
||||||
src/components/molecules/ — SearchBar, PriceCard, ServiceOption, etc.
|
|
||||||
src/components/organisms/ — Navigation, Footer, ArrangementDialog, etc.
|
|
||||||
src/components/templates/ — WizardLayout
|
|
||||||
src/components/pages/ — IntroStep, ProvidersStep, etc. (wizard steps)
|
|
||||||
tokens/primitives/ — Raw values (hex, px, font names)
|
|
||||||
tokens/semantic/ — Design intent (color.text.primary)
|
|
||||||
tokens/component/ — Per-component tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
## When making visual changes
|
|
||||||
|
|
||||||
|
When reviewing or making visual changes:
|
||||||
1. Make the code change
|
1. Make the code change
|
||||||
2. Open the Storybook story in the browser to verify
|
2. Open the Storybook story in the browser at `localhost:6006`
|
||||||
3. Screenshot and check spacing, alignment, visual weight
|
3. Screenshot and check spacing, alignment, visual weight
|
||||||
4. Self-correct if something looks off before presenting
|
4. Self-correct if something looks off before presenting
|
||||||
|
|
||||||
|
Story URL pattern: `/story/{tier}-{componentname}--{variantname}`
|
||||||
|
|
||||||
|
## Cross-tool workflow
|
||||||
|
|
||||||
|
This project uses both Claude Code and Google Antigravity.
|
||||||
|
See `docs/reference/cross-tool-workflow.md` for task routing and coordination.
|
||||||
|
|
||||||
|
Antigravity owns: visual QA, browser verification, page-level review screenshots.
|
||||||
|
Claude Code owns: component building, token creation, code quality, memory updates, git.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
Custom workflows available in `.agent/workflows/`:
|
||||||
|
`fa-session-start`, `fa-preflight`, `fa-token-sync`, `fa-visual-qa`, `fa-build-component`, `fa-page-review`
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
|
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
|
||||||
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
|
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
|
||||||
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
|
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
|
||||||
|
| MapPin | done | name+price (two-line), name-only, price-only × verified, unverified × default, active | mapPin.paddingX/borderRadius/nub.size, color.brand.100-900, color.neutral.100-800 | Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Pure CSS. role="button" + keyboard + focus ring. |
|
||||||
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
|
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
|
||||||
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
||||||
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
||||||
@@ -39,6 +40,8 @@ duplicates) and MUST update it after completing one.
|
|||||||
|
|
||||||
| Component | Status | Composed of | Notes |
|
| Component | Status | Composed of | Notes |
|
||||||
|-----------|--------|-------------|-------|
|
|-----------|--------|-------------|-------|
|
||||||
|
| MiniCard | done | Card + Typography + Badge + Tooltip | Compact vertical card for grids, recommendations, map popups. Image + title + optional price/badges/chips/meta (location, rating, capacity). Verified = icon-only circle badge in image. Hierarchy: title → meta → price → badges → chips. Truncated title shows tooltip. 3 component tokens. Audit: 20/20. |
|
||||||
|
| MapPopup | done | Paper + Typography + Tooltip | Floating map popup anchored to MapPin. Clickable card (onClick). Image + name (1 line, tooltip) + meta + price. Verified = icon-only circle badge in image (matches MiniCard). Hierarchy matches MiniCard. Nub + drop-shadow. 260px wide. |
|
||||||
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
|
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
|
||||||
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
|
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
|
||||||
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
|
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
|
||||||
@@ -50,6 +53,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| LineItem | done | Typography + Tooltip + InfoOutlinedIcon | Name + optional info tooltip + optional price. Supports allowance asterisk, total variant (bold + top border). Font weight 500 (D019), prices text.secondary for readability hierarchy. Audit: 19/20. |
|
| LineItem | done | Typography + Tooltip + InfoOutlinedIcon | Name + optional info tooltip + optional price. Supports allowance asterisk, total variant (bold + top border). Font weight 500 (D019), prices text.secondary for readability hierarchy. Audit: 19/20. |
|
||||||
| ProviderCardCompact | done | Card (outlined) + Typography | Horizontal compact provider card — image left, name + location + rating right. Used at top of Package Select page. Separate from vertical ProviderCard. |
|
| ProviderCardCompact | done | Card (outlined) + Typography | Horizontal compact provider card — image left, name + location + rating right. Used at top of Package Select page. Separate from vertical ProviderCard. |
|
||||||
| CartButton | done | Button + DialogShell + LineItem + Divider + Typography | Outlined pill trigger: receipt icon + "Your Plan" + formatted total in brand colour. Click opens DialogShell with items grouped by section via LineItem, total row. Mobile: icon + price only. Lives in WizardLayout `runningTotal` slot. |
|
| CartButton | done | Button + DialogShell + LineItem + Divider + Typography | Outlined pill trigger: receipt icon + "Your Plan" + formatted total in brand colour. Click opens DialogShell with items grouped by section via LineItem, total row. Mobile: icon + price only. Lives in WizardLayout `runningTotal` slot. |
|
||||||
|
| CompareBar | done | Badge + Button + IconButton + Typography + Paper + Slide | Floating comparison basket pill. Fixed bottom, slide-up/down. Package count badge + provider names + remove buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline error for max-reached. Mobile: compact count + CTA only. Audit: 18/20. |
|
||||||
|
|
||||||
## Organisms
|
## Organisms
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ duplicates) and MUST update it after completing one.
|
|||||||
|-----------|--------|-------------|-------|
|
|-----------|--------|-------------|-------|
|
||||||
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
|
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
|
||||||
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
||||||
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). |
|
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections: Essentials + Optionals (before total) + total + Extras (after total, with subtext). `priceLabel` pass-through to LineItem (D039). T&C grey footer. Audit: 19/20. |
|
||||||
|
| ComparisonTable | done | Typography + Button + Badge + Link + Tooltip | Side-by-side package comparison CSS Grid. Sticky header cards with provider info + price + CTA. Row-merged sections (union of all items). 7 cell value types (discriminated union D036). Recommended column with warm bg + badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA table roles. Desktop only (mobile in ComparisonPage). Audit: 17/20. |
|
||||||
| FuneralFinder (V3) | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | **Production version.** Hero search widget — clean form with status cards. Standard card container (surface.raised, card shadow). "How Can We Help" section: two side-by-side StatusCards (Immediate Need default-selected / Pre-planning) — white bg, neutral border, brand border + warm bg when selected, stack on mobile. "Funeral Type" Select + "Location" OutlinedInput with pin icon — standard outlined fields, no focus ring (per design). Overline section labels (text.secondary). CTA "Find Funeral Directors →" always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". Dividers after header and before CTA. WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). |
|
| FuneralFinder (V3) | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | **Production version.** Hero search widget — clean form with status cards. Standard card container (surface.raised, card shadow). "How Can We Help" section: two side-by-side StatusCards (Immediate Need default-selected / Pre-planning) — white bg, neutral border, brand border + warm bg when selected, stack on mobile. "Funeral Type" Select + "Location" OutlinedInput with pin icon — standard outlined fields, no focus ring (per design). Overline section labels (text.secondary). CTA "Find Funeral Directors →" always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". Dividers after header and before CTA. WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). |
|
||||||
| FuneralFinder V1 | archived | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Archived — viewable in Storybook under Archive/. Stepped conversational flow. Audit: 14/20. Critique: 29/40. |
|
| FuneralFinder V1 | archived | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Archived — viewable in Storybook under Archive/. Stepped conversational flow. Audit: 14/20. Critique: 29/40. |
|
||||||
| FuneralFinder V2 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Archived — viewable in Storybook under Archive/. Quick-form with step circles. Audit: 18/20. Critique: 33/40. |
|
| FuneralFinder V2 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Archived — viewable in Storybook under Archive/. Quick-form with step circles. Audit: 18/20. Critique: 33/40. |
|
||||||
@@ -97,6 +102,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| ConfirmationStep | done | WizardLayout (centered-form) + Button | Wizard step 15 — confirmation. Terminal page. At-need: "submitted" + callback. Pre-planning: "saved" + return-anytime. Muted success icon. |
|
| ConfirmationStep | done | WizardLayout (centered-form) + Button | Wizard step 15 — confirmation. Terminal page. At-need: "submitted" + callback. Pre-planning: "saved" + return-anytime. Muted success icon. |
|
||||||
| UnverifiedProviderStep | done | WizardLayout (list-detail) + ProviderCardCompact + ProviderCard + Badge + Button + Divider + Typography | Unverified provider detail. Left: compact card + "Listing" badge + available info (conditional dl) + verified recommendations. Right: warm header band + detail rows + "Make an Enquiry" CTA. Graceful degradation (no data → straight to enquiry). 4 story variants. |
|
| UnverifiedProviderStep | done | WizardLayout (list-detail) + ProviderCardCompact + ProviderCard + Badge + Button + Divider + Typography | Unverified provider detail. Left: compact card + "Listing" badge + available info (conditional dl) + verified recommendations. Right: warm header band + detail rows + "Make an Enquiry" CTA. Graceful degradation (no data → straight to enquiry). 4 story variants. |
|
||||||
| HomePage | done | FuneralFinderV3/V4 (via finderSlot) + ProviderCardCompact + Button + Typography + Accordion + Divider + Navigation (prop) + Footer (prop) | Marketing landing page. 4 archived versions: V1 (split hero), V2 (full-bleed parsonshero.png), V3 (hero-3.png + updated copy + logo bar + venue photos + warm CTA gradient), V4 (same as V3 but with FuneralFinderV4 stepped form via finderSlot). `finderSlot` prop allows swapping finder widget. Light grey footer (surface.subtle). |
|
| HomePage | done | FuneralFinderV3/V4 (via finderSlot) + ProviderCardCompact + Button + Typography + Accordion + Divider + Navigation (prop) + Footer (prop) | Marketing landing page. 4 archived versions: V1 (split hero), V2 (full-bleed parsonshero.png), V3 (hero-3.png + updated copy + logo bar + venue photos + warm CTA gradient), V4 (same as V3 but with FuneralFinderV4 stepped form via finderSlot). `finderSlot` prop allows swapping finder widget. Light grey footer (surface.subtle). |
|
||||||
|
| ComparisonPage | done | WizardLayout (wide-form) + ComparisonTable + Chip + Card + LineItem + Typography + Button + Divider | Package comparison page. Desktop: full ComparisonTable with sticky headers. Mobile: tabbed card view with horizontal chip rail (role="tablist") + single package card (role="tabpanel"). Recommended package as additional column/tab (separate prop D038). Back link, help bar. |
|
||||||
|
|
||||||
## Future enhancements
|
## Future enhancements
|
||||||
|
|
||||||
|
|||||||
@@ -293,3 +293,43 @@ contradict a previous one.
|
|||||||
**Rationale:** P0/P1 are the issues that affect usability and accessibility. P2/P3 are cosmetic — not worth the risk of changing approved components. Interleaving ensures the foundation is solid before building on it, without dedicating entire sessions to review.
|
**Rationale:** P0/P1 are the issues that affect usability and accessibility. P2/P3 are cosmetic — not worth the risk of changing approved components. Interleaving ensures the foundation is solid before building on it, without dedicating entire sessions to review.
|
||||||
**Affects:** Session workflow, CLAUDE.md startup procedure, docs/reference/retroactive-review-plan.md
|
**Affects:** Session workflow, CLAUDE.md startup procedure, docs/reference/retroactive-review-plan.md
|
||||||
**Alternatives considered:** Dedicated review sessions — rejected as less efficient. Full P0-P3 fixes — rejected as too risky for approved components.
|
**Alternatives considered:** Dedicated review sessions — rejected as less efficient. Full P0-P3 fixes — rejected as too risky for approved components.
|
||||||
|
|
||||||
|
### D035 — Package sections standardised to Essentials / Optionals / Extras
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Category:** component
|
||||||
|
**Decision:** Package data uses three sections: **Essentials** (priced core items), **Optionals** (complimentary inclusions), **Extras** (additional-cost items after the total). Replaces the previous "Complimentary Items" naming.
|
||||||
|
**Rationale:** Matches the real-world package structure from FA's provider data (see reference image). "Optionals" better communicates that these are included-but-not-mandatory items, while "Complimentary" is a price label on individual items, not a section name.
|
||||||
|
**Affects:** PackageDetail stories, ComparisonTable sections, ComparisonPage mobile cards
|
||||||
|
**Alternatives considered:** "Inclusions" instead of "Optionals" — rejected as it overlaps with Essentials (which are also inclusions).
|
||||||
|
|
||||||
|
### D036 — ComparisonCellValue uses discriminated union type
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Category:** architecture
|
||||||
|
**Decision:** Cell values in ComparisonTable use a tagged union type (`{ type: 'price' | 'allowance' | 'complimentary' | 'included' | 'poa' | 'unknown' | 'unavailable' }`) rather than flat optional props.
|
||||||
|
**Rationale:** Ensures exhaustive pattern matching in CellValue renderer — the TypeScript compiler catches missing cases. Clearer than a flat `{ price?: number; priceLabel?: string; isAllowance?: boolean }` which has ambiguous combinations. Each value type maps to a distinct visual treatment.
|
||||||
|
**Affects:** ComparisonTable, ComparisonPage mobile card view
|
||||||
|
**Alternatives considered:** Reusing PackageLineItem from PackageDetail — rejected as it conflates "how data is stored" with "how data is displayed". The comparison needs explicit cell state (e.g. "unavailable" vs "unknown").
|
||||||
|
|
||||||
|
### D037 — Mobile comparison uses chip tabs, not horizontal scroll table
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Category:** component
|
||||||
|
**Decision:** ComparisonPage renders a chip-based tab rail + single card view on mobile, rather than a horizontally scrollable table.
|
||||||
|
**Rationale:** Wide comparison tables on small screens create "hidden column" problems — users can't see all packages at once and may miss columns. Card view with tabs matches mental model of reviewing one option at a time. Lower cognitive load for FA's grief-sensitive audience. Tab rail provides quick switching. ARIA tablist/tabpanel semantics.
|
||||||
|
**Affects:** ComparisonPage mobile layout
|
||||||
|
**Alternatives considered:** Horizontal scroll table — rejected for poor usability on small screens. Accordion per package — rejected as it hides content behind extra taps.
|
||||||
|
|
||||||
|
### D038 — Recommended package is a separate prop, not mixed into packages array
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Category:** architecture
|
||||||
|
**Decision:** ComparisonPage accepts `recommendedPackage` as a separate prop from `packages`. The page merges it as the last column with `isRecommended: true`.
|
||||||
|
**Rationale:** Keeps the user-selected array clean and unambiguous. The recommendation source is explicit (server-side logic). The page controls placement (always last column/tab). Prevents accidental removal of the recommended package by the user (no Remove button).
|
||||||
|
**Affects:** ComparisonPage props, ComparisonTable isRecommended column
|
||||||
|
**Alternatives considered:** Including recommended in the packages array with a flag — rejected as it mixes user selections with system recommendations.
|
||||||
|
|
||||||
|
### D039 — PackageLineItem gains priceLabel for consistency with LineItem
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Category:** component
|
||||||
|
**Decision:** Added `priceLabel?: string` to `PackageLineItem` interface in PackageDetail, passed through to LineItem molecule.
|
||||||
|
**Rationale:** LineItem already supports `priceLabel` for custom price text ("Complimentary", "Price On Application", "TBC"). PackageDetail's type was missing this field, forcing workarounds. Adding it enables the Optionals section to display "Complimentary" labels and Extras to show "Price On Application".
|
||||||
|
**Affects:** PackageDetail component + stories, any consumer of PackageLineItem type
|
||||||
|
**Alternatives considered:** None — this was a straightforward type parity fix.
|
||||||
|
|||||||
@@ -26,6 +26,122 @@ Each entry follows this structure:
|
|||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
|
### Session 2026-04-07 — Package comparison iteration (Figma-informed)
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
- **ComparisonTable major iteration** from Figma feedback:
|
||||||
|
- Dark info card → soft grey info card (surface.subtle, no border), stretches to match card heights, text at top
|
||||||
|
- Provider cards: no logos, floating verified badge (VerifiedOutlinedIcon, consistent with ProviderCard/MiniCard/MapPopup), rating in cards (body2 size)
|
||||||
|
- Separate bordered tables per section (Essentials, Optionals, Extras) with left accent borders (3px brand-500)
|
||||||
|
- Reviews section removed (rating lives in cards)
|
||||||
|
- Horizontal scroll on narrow desktops (minWidth enforcement)
|
||||||
|
- Cards: flex stretch + spacer for CTA bottom-alignment across mixed verified/unverified
|
||||||
|
- Row hover highlight (brand-50), font hierarchy (labels text.secondary, values fontWeight 600)
|
||||||
|
- **ComparisonPage iteration:**
|
||||||
|
- Share + Print buttons in page header (onShare, onPrint props)
|
||||||
|
- Mobile verified badge (VerifiedOutlinedIcon in soft brand Badge)
|
||||||
|
- Mobile section headings with left accent borders
|
||||||
|
- Mobile item rows: 60% max-width for names, inline info icons with nowrap binding
|
||||||
|
- Mobile tab rail: mini Card components (provider name + package name) replacing Chips
|
||||||
|
- Navigation included by default in all stories
|
||||||
|
- **CompareBar simplified:**
|
||||||
|
- Fraction badge (1/3, 2/3, 3/3)
|
||||||
|
- Contextual copy: "Add another to compare" / "Ready to compare"
|
||||||
|
- Removed package names and remove buttons from pill
|
||||||
|
- **Figma integration:**
|
||||||
|
- Created `/capture-to-figma` skill — captures Storybook stories to Parsons Figma file
|
||||||
|
- Created `/figma-ideas` skill — fetches Figma designs and proposes adaptations
|
||||||
|
- Successfully captured ComparisonPage to Figma (node 6041-25005)
|
||||||
|
- Applied user's Figma tweaks (node 6047-25005) back to code
|
||||||
|
- **Cleanup:** Removed Figma capture script from preview-head.html, Prettier formatting pass
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- Info card uses surface.subtle (not dark), stretches to match cards — less visually competing
|
||||||
|
- Verified badge uses VerifiedOutlinedIcon (consistent with rest of system), floating above cards
|
||||||
|
- Rating lives in card headers, no separate Reviews table
|
||||||
|
- Section tables separated with left accent borders (3px brand-500)
|
||||||
|
- Mobile tab rail uses mini Cards (provider + package name) not Chips
|
||||||
|
- Share/Print are optional props on ComparisonPage
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- Commit all work
|
||||||
|
- Wire CompareBar into PackagesStep/ProvidersStep (state management)
|
||||||
|
- Consider comparison state persistence (URL params or context)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session 2026-04-06b — Package comparison feature
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
- **PackageDetail fix (D039):** Added `priceLabel?: string` to `PackageLineItem` interface, passed through to LineItem. Updated stories to use Essentials/Optionals/Extras sections with realistic funeral data (D035). "Complimentary Items" → "Optionals".
|
||||||
|
- **CompareBar molecule (new):** Floating comparison basket pill. Fixed bottom, slide-up/down animation. Badge count + provider names + remove × buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline `role="alert"` error for max-reached. Mobile: compact count + CTA only. Audit: 18/20 (P2s fixed: error visible on mobile, removed redundant aria-disabled).
|
||||||
|
- **ComparisonTable organism (new):** CSS Grid side-by-side comparison. Sticky header cards with provider logo/name/location/rating + package name + price + CTA. Row-merged sections via `buildMergedSections` union algorithm. 7 cell value types via discriminated union (D036). Recommended column with warm bg + Badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA `role="table"` + `role="row"` + `role="columnheader"` + `role="cell"`. Desktop only. Audit: 17/20 (P2s fixed: aria-label on recommended column, rowheader on section headings, token-based zebra striping).
|
||||||
|
- **ComparisonPage page (new):** WizardLayout (wide-form). Desktop: full ComparisonTable. Mobile: chip tab rail (`role="tablist"`) + single MobilePackageCard (`role="tabpanel"`). Recommended package as separate prop, merged as last column/tab. Back link, help bar.
|
||||||
|
- **Stories:** 6 CompareBar stories (Default, SinglePackage, ThreePackages, WithError, Empty, Interactive), 5 ComparisonTable stories (Default, TwoPackages, WithRecommended, MixedVerified, MissingData), 5 ComparisonPage stories (Default, TwoPackages, WithRecommended, MobileView, FullPage with Navigation).
|
||||||
|
- **Quality gates:** TypeScript ✓, ESLint ✓, Storybook build ✓. CompareBar audit 18/20, ComparisonTable audit 17/20.
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- D035: Package sections standardised to Essentials/Optionals/Extras
|
||||||
|
- D036: ComparisonCellValue uses discriminated union for exhaustive rendering
|
||||||
|
- D037: Mobile comparison uses chip tabs + card view, not horizontal scroll table
|
||||||
|
- D038: Recommended package is a separate prop, always additional to user selections
|
||||||
|
- D039: PackageLineItem gains priceLabel for consistency with LineItem molecule
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- Visual review in Storybook (user + Playwright screenshots)
|
||||||
|
- Wire CompareBar into PackagesStep (state management for comparison basket)
|
||||||
|
- Consider adding CompareBar to WizardLayout as a slot or portal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session 2026-04-06 — Retroactive review completion
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
- **Phase 1 atoms complete:** Audited Typography (18/20, no P0/P1) and Badge (15/20, P0 `role="status"` determined false positive — would create unwanted aria-live on static labels)
|
||||||
|
- **Phase 2 molecules complete:** Normalized all 9 molecules — displayName ✓, forwardRef ✓, ARIA ✓, no hardcoded colours. Flagged AddOnOption/ProviderCard ARIA issues reviewed and determined false positives (Switch is semantic control; ProviderCard has `...rest` passthrough)
|
||||||
|
- **Phase 3 organisms complete:** Normalized 5 active organisms. Audited Navigation (15/20, no real P0/P1 — focus-visible from MUI theme, CSS vars D031-compliant) and ServiceSelector (17/20, **fixed P0: added `aria-required` to radiogroup**)
|
||||||
|
- **Phase 4 preflight:** TypeScript ✓, ESLint ✓, Prettier ✓, Storybook build ✓
|
||||||
|
- **Review plan updated:** All phases marked done. Only `/typeset` deferred (low risk)
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- Badge `role="status"` rejected: static status labels shouldn't be aria-live regions
|
||||||
|
- AddOnOption `role="checkbox"` rejected: Switch is the semantic control, Card click is convenience
|
||||||
|
- CSS var usage in organisms is D031-compliant (CSS vars acceptable for semantic tokens per D031)
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- From 2026-04-01: Which HomePage version (V3 or V4) is production?
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- User has components to change/build — shifting to that work
|
||||||
|
|
||||||
|
**Work completed (continued):**
|
||||||
|
- **MiniCard molecule (new + iterated):** Compact vertical card. Image + verified icon-only badge (copper circle, top-right) + title (h6, 2-line max with tooltip on truncation) + meta row + price + badges + chips. Hierarchy: title → meta → price → badges → chips. 3 component tokens. Audit: 20/20.
|
||||||
|
- **MapPin atom (new + redesigned):** Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold 600). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Padding bumped from 8px to 12px for readability. Pure CSS. role="button" + keyboard + focus ring.
|
||||||
|
- **MapPopup molecule (new + iterated):** Clickable floating card (onClick). Image + verified icon badge (matches MiniCard) + name (1-line, tooltip on truncation) + meta + price. Hierarchy matches MiniCard. No-image fallback shows inline verified icon + text. Nub + drop-shadow. 260px wide.
|
||||||
|
- **Component tokens:** miniCard.json and mapPin.json added to Style Dictionary, rebuilt CSS/JS/TS outputs (407 declarations total).
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- MiniCard uses `h6` for title (smaller than ProviderCard's `h5`), `caption` for meta, `body2` for price
|
||||||
|
- MiniCard verified badge is icon-only circle (not text chip) — compact, matches map popup
|
||||||
|
- MapPin redesigned from price-only pill to two-line name+price label — transparency and clarity
|
||||||
|
- MapPin price centred under name — left-aligned looked unbalanced (visually reviewed)
|
||||||
|
- MapPin price weight 600 (semibold) — 500 was too thin at 11px
|
||||||
|
- MapPin padding 12px (was 8px) — names were tight against edges
|
||||||
|
- MapPopup whole card clickable — removed "View details" link
|
||||||
|
- MapPopup name 1-line limit (was 2) with tooltip — tighter for map context
|
||||||
|
- Chips in MiniCard rendered as soft default Badges (no interactive Chip atom) for visual simplicity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Session 2026-04-01 — FuneralFinder V4, HomePage V3/V4, Footer restyle
|
### Session 2026-04-01 — FuneralFinder V4, HomePage V3/V4, Footer restyle
|
||||||
|
|
||||||
**Agent(s):** Claude Opus 4.6 (1M context)
|
**Agent(s):** Claude Opus 4.6 (1M context)
|
||||||
|
|||||||
184
docs/reference/cross-tool-workflow.md
Normal file
184
docs/reference/cross-tool-workflow.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Cross-Tool Workflow: Claude Code + Google Antigravity
|
||||||
|
|
||||||
|
## How the tools work together
|
||||||
|
|
||||||
|
**Claude Code** (terminal) — code generation, refactoring, quality analysis, memory
|
||||||
|
management. Has 18 custom skills, 3 agents, MCP access (Figma, Storybook).
|
||||||
|
Strengths: deep code reasoning, multi-file refactors, token architecture, grief-sensitive
|
||||||
|
copy review, git operations.
|
||||||
|
|
||||||
|
**Antigravity** (IDE) — visual verification, browser interaction, page-level review.
|
||||||
|
Has 6 custom workflows in `.agent/workflows/`. Strengths: built-in browser for
|
||||||
|
Storybook verification, screenshot comparison across breakpoints, multi-agent
|
||||||
|
parallelism via Manager View.
|
||||||
|
|
||||||
|
**Both read:** `AGENTS.md` (shared rules), `docs/memory/` (shared state), all source files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task routing
|
||||||
|
|
||||||
|
### Always Claude Code
|
||||||
|
|
||||||
|
- Creating new components (`/build-atom`, `/build-molecule`, `/build-organism`)
|
||||||
|
- Token creation and architecture (`/create-tokens`, `/sync-tokens`)
|
||||||
|
- Code quality analysis (`/audit`, `/critique`, `/harden`, `/normalize`)
|
||||||
|
- Writing tests and stories (`/write-tests`, `/write-stories`)
|
||||||
|
- Visual polish skills (`/polish`, `/typeset`, `/adapt`, `/quieter`, `/clarify`)
|
||||||
|
- Memory file updates (decisions-log, component-registry, session-log)
|
||||||
|
- Git operations (commit, push)
|
||||||
|
- Multi-file refactors
|
||||||
|
- Figma MCP interactions (design extraction)
|
||||||
|
|
||||||
|
### Always Antigravity
|
||||||
|
|
||||||
|
- Browser-based visual QA (`/fa-visual-qa` workflow)
|
||||||
|
- Screenshot comparison across breakpoints (375/768/1280px)
|
||||||
|
- Page-level visual review (`/fa-page-review` workflow)
|
||||||
|
- Storybook visual regression checks
|
||||||
|
|
||||||
|
### Either tool (user's choice)
|
||||||
|
|
||||||
|
- Preflight checks (Claude: `/preflight` | Antigravity: `/fa-preflight`)
|
||||||
|
- Token sync rebuild (Claude: `/sync-tokens` | Antigravity: `/fa-token-sync`)
|
||||||
|
- Session status report (Claude: `/status` | Antigravity: `/fa-session-start`)
|
||||||
|
- Reading and analysing existing code
|
||||||
|
- Copy and content review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sandwich workflow
|
||||||
|
|
||||||
|
Every significant change should follow a three-step pattern that uses both tools.
|
||||||
|
|
||||||
|
### For new components or features
|
||||||
|
|
||||||
|
1. **Plan (Claude Code):** Read memory files, check registry, design the approach
|
||||||
|
2. **Build (Claude Code):** Implement — component, tokens, stories, tests
|
||||||
|
3. **Verify (Antigravity):** Open in Storybook, screenshot at 3 breakpoints, report issues
|
||||||
|
|
||||||
|
### For page tweaking (current phase)
|
||||||
|
|
||||||
|
1. **Assess (Antigravity):** Run `/fa-page-review`, screenshot current state, identify issues
|
||||||
|
2. **Fix (Claude Code):** Make targeted fixes to spacing, copy, alignment, responsive
|
||||||
|
3. **Verify (Antigravity):** Re-run `/fa-visual-qa`, confirm fixes, screenshot final state
|
||||||
|
|
||||||
|
The sandwich ensures no change goes live without both code quality AND visual verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
The 10-stage component lifecycle (`docs/reference/component-lifecycle.md`) applies
|
||||||
|
regardless of which tool does the work.
|
||||||
|
|
||||||
|
| Stage | Primary tool | Gate |
|
||||||
|
|-------|-------------|------|
|
||||||
|
| 1. BUILD | Claude Code | — |
|
||||||
|
| 2. STORIES | Claude Code | — |
|
||||||
|
| 3. INTERNAL QA | Claude Code (`/audit`, `/critique`, `/harden`) | P0 = blocking |
|
||||||
|
| 4. FIX | Claude Code | Until P0/P1 = 0 |
|
||||||
|
| 5. POLISH | Claude Code (`/polish`, `/typeset`, `/adapt`) | — |
|
||||||
|
| 6. PRESENT | Antigravity (`/fa-visual-qa`) | Visual sign-off |
|
||||||
|
| 7. ITERATE | Both (Claude fixes, Antigravity verifies) | User approves |
|
||||||
|
| 8. NORMALIZE | Claude Code (`/normalize`) | — |
|
||||||
|
| 9. PREFLIGHT | Either (`/preflight` or `/fa-preflight`) | Critical = blocking |
|
||||||
|
| 10. COMMIT | Claude Code | — |
|
||||||
|
|
||||||
|
**Enforcement:** No component moves past PRESENT without Antigravity visual sign-off.
|
||||||
|
No component moves to COMMIT without preflight passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File ownership boundaries
|
||||||
|
|
||||||
|
### Current phase (frontend only)
|
||||||
|
|
||||||
|
| Scope | Claude Code | Antigravity |
|
||||||
|
|-------|------------|-------------|
|
||||||
|
| `src/components/**/*.tsx` | write | read only |
|
||||||
|
| `src/components/**/*.stories.tsx` | write | read only |
|
||||||
|
| `src/components/**/index.ts` | write | read only |
|
||||||
|
| `tokens/**/*.json` | write | read only |
|
||||||
|
| `src/theme/**` | write | read only |
|
||||||
|
| `docs/memory/**` | write | read only |
|
||||||
|
| `docs/conventions/**`, `docs/reference/**` | write | read only |
|
||||||
|
| `.agent/workflows/*.md` | read only | write (own workflows) |
|
||||||
|
| `AGENTS.md` | write (coordinate first) | write (coordinate first) |
|
||||||
|
| `CLAUDE.md` | write | never |
|
||||||
|
| `GEMINI.md` | never | write |
|
||||||
|
|
||||||
|
### When backend starts (Payload CMS)
|
||||||
|
|
||||||
|
- **Backend agent writes:** `src/payload/**`, `src/api/**`, database schemas, server config
|
||||||
|
- **Frontend agents never touch:** anything in `src/payload/` or `src/api/`
|
||||||
|
- **Backend agent never touches:** `src/components/**`, `tokens/**`, `src/theme/**`
|
||||||
|
- **Shared contract:** `documentation/flow-definition.yaml` is the interface between
|
||||||
|
frontend and backend. Field names, step IDs, and visibility rules in that file
|
||||||
|
dictate both component props and API data shapes. Changes require both sides
|
||||||
|
to acknowledge.
|
||||||
|
|
||||||
|
### Conflict resolution
|
||||||
|
|
||||||
|
- If two agents need to edit the same file: **STOP**, flag it in `docs/memory/session-log.md`,
|
||||||
|
let the user decide who proceeds.
|
||||||
|
- If agents produce conflicting output: `docs/memory/decisions-log.md` is the tiebreaker.
|
||||||
|
Whoever's approach matches a logged decision wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error mitigation
|
||||||
|
|
||||||
|
### Pre-flight checklist (before any agent work)
|
||||||
|
|
||||||
|
- [ ] Read `docs/memory/session-log.md` — is another agent mid-task?
|
||||||
|
- [ ] Read `docs/memory/component-registry.md` — is the target component in-progress by someone else?
|
||||||
|
- [ ] Confirm file ownership — am I allowed to write these files?
|
||||||
|
- [ ] Storybook running? (`localhost:6006`)
|
||||||
|
- [ ] Git clean? No uncommitted changes from a previous session
|
||||||
|
|
||||||
|
### Memory file update protocol
|
||||||
|
|
||||||
|
1. Read the file before writing (always)
|
||||||
|
2. Append, do not overwrite existing entries
|
||||||
|
3. Use the established format (each file documents its own format)
|
||||||
|
4. Include decision IDs (D001, D002...) for traceability
|
||||||
|
5. Log agent name and date in every session-log entry
|
||||||
|
|
||||||
|
### Verification after every change
|
||||||
|
|
||||||
|
- TypeScript compiles: `npx tsc --noEmit`
|
||||||
|
- Storybook renders the specific story (not just builds)
|
||||||
|
- Visual check: at least desktop breakpoint for small changes, all three for layout changes
|
||||||
|
- Token sync: if tokens changed, run `npm run build:tokens` and verify output
|
||||||
|
|
||||||
|
### When agents produce conflicting output
|
||||||
|
|
||||||
|
1. Check `docs/memory/decisions-log.md` for a prior decision
|
||||||
|
2. If a decision exists: follow it, flag the conflict in session-log
|
||||||
|
3. If no decision exists: **STOP**, do not proceed, ask the user
|
||||||
|
4. Never silently override another agent's work
|
||||||
|
|
||||||
|
### Common failure modes and recovery
|
||||||
|
|
||||||
|
| Failure | Symptom | Recovery |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| Stale tokens | Generated files older than source JSON | Run `npm run build:tokens` |
|
||||||
|
| Hardcoded values | Hex colours in `.tsx` files | Run preflight scan, replace with theme refs |
|
||||||
|
| Memory drift | Session log not updated | Always update session-log as last step |
|
||||||
|
| Story rendering failure | Component changed but stories broken | Run `/write-stories` to regenerate |
|
||||||
|
| File ownership violation | Two agents edited same file | `git diff` to see both changes, manually merge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adapting for backend work
|
||||||
|
|
||||||
|
When Payload CMS implementation begins:
|
||||||
|
|
||||||
|
1. Add a `## Backend (Payload CMS)` section to `AGENTS.md` with Payload-specific rules
|
||||||
|
2. Add backend file ownership to the boundaries above
|
||||||
|
3. `documentation/flow-definition.yaml` becomes the API contract — field names, step IDs,
|
||||||
|
and visibility rules dictate both frontend props and backend data shapes
|
||||||
|
4. Backend agent reads `documentation/flow-definition.yaml` and `documentation/steps/*.yaml`
|
||||||
|
as its primary specifications
|
||||||
|
5. Frontend agents continue unchanged — the boundary is the data interface, not the UI
|
||||||
211
docs/reference/how-to-work-with-both-tools.md
Normal file
211
docs/reference/how-to-work-with-both-tools.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# How to Work with Claude Code and Antigravity
|
||||||
|
|
||||||
|
A plain-English guide for day-to-day use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The basic idea
|
||||||
|
|
||||||
|
You have two AI tools open at once. Claude Code runs in the terminal (inside
|
||||||
|
Antigravity or standalone). Antigravity is the IDE with a built-in browser and
|
||||||
|
its own AI chat panel. They each have strengths, and the setup is designed so
|
||||||
|
they don't step on each other.
|
||||||
|
|
||||||
|
Think of it like having two specialists in the room:
|
||||||
|
- **Claude Code** is the engineer — writes code, manages tokens, runs quality
|
||||||
|
checks, handles git, keeps the project memory files up to date.
|
||||||
|
- **Antigravity** is the visual reviewer — opens Storybook in a real browser,
|
||||||
|
takes screenshots, checks how things look at different screen sizes, spots
|
||||||
|
spacing and alignment issues.
|
||||||
|
|
||||||
|
They share the same project files and the same set of rules (`AGENTS.md`), so
|
||||||
|
they're always on the same page about conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use Claude Code
|
||||||
|
|
||||||
|
Use the terminal (Claude Code) when you need something **built, fixed, or analysed**.
|
||||||
|
|
||||||
|
**Building things:**
|
||||||
|
- "Build me a new Button atom" → Claude Code uses `/build-atom`
|
||||||
|
- "Create tokens for this new component" → Claude Code uses `/create-tokens`
|
||||||
|
- "Write stories for the VenueCard" → Claude Code uses `/write-stories`
|
||||||
|
|
||||||
|
**Fixing things:**
|
||||||
|
- "The spacing on ProvidersStep is off" → tell Claude Code what to fix
|
||||||
|
- "This component needs to handle empty states" → Claude Code edits the code
|
||||||
|
- "Update the copy on the intro page" → Claude Code modifies the JSX
|
||||||
|
|
||||||
|
**Quality checks:**
|
||||||
|
- "Audit this component" → Claude Code runs `/audit`, `/critique`, `/harden`
|
||||||
|
- "Is this ready to commit?" → Claude Code runs `/preflight`
|
||||||
|
- "Check consistency across all atoms" → Claude Code runs `/normalize atoms`
|
||||||
|
|
||||||
|
**Project management:**
|
||||||
|
- "What did we do last session?" → Claude Code reads the memory files
|
||||||
|
- "Log this design decision" → Claude Code updates decisions-log.md
|
||||||
|
- "Commit and push" → Claude Code handles git
|
||||||
|
|
||||||
|
**Rule of thumb:** if it involves writing or changing code, tokens, or memory
|
||||||
|
files — use Claude Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use Antigravity
|
||||||
|
|
||||||
|
Use the Antigravity chat panel when you need something **looked at visually**.
|
||||||
|
|
||||||
|
**Visual QA:**
|
||||||
|
- "How does the HomePage look on mobile?" → type `/fa-visual-qa HomePage` in
|
||||||
|
Antigravity. It opens the story, checks at 375px, 768px, and 1280px, takes
|
||||||
|
screenshots, and reports issues.
|
||||||
|
|
||||||
|
**Page review (your most common task right now):**
|
||||||
|
- "Review the CoffinsStep" → type `/fa-page-review CoffinsStep`. It reads the
|
||||||
|
source, opens the story, runs through the review checklist (spacing, typography,
|
||||||
|
colour, responsive, copy, interactions, edge cases), and reports findings with
|
||||||
|
severity ratings.
|
||||||
|
|
||||||
|
**Quick checks:**
|
||||||
|
- "Does this look right?" → open Storybook in Antigravity's browser panel and
|
||||||
|
eyeball it yourself. The built-in browser is faster than using Playwright
|
||||||
|
screenshots through Claude Code.
|
||||||
|
|
||||||
|
**Pre-commit (either tool works):**
|
||||||
|
- `/fa-preflight` in Antigravity runs the same checks as `/preflight` in Claude
|
||||||
|
Code. Use whichever you have open.
|
||||||
|
|
||||||
|
**Rule of thumb:** if it involves looking at something in a browser and judging
|
||||||
|
whether it looks right — use Antigravity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The typical flow for your current work (tweaking pages)
|
||||||
|
|
||||||
|
Most of your work right now is reviewing and polishing existing pages. Here's the
|
||||||
|
natural rhythm:
|
||||||
|
|
||||||
|
### 1. Start your session
|
||||||
|
|
||||||
|
In Claude Code: I automatically read the memory files and report what's pending.
|
||||||
|
Or in Antigravity: type `/fa-session-start` for the same status report.
|
||||||
|
|
||||||
|
### 2. Pick a page to work on
|
||||||
|
|
||||||
|
You say something like "let's look at the ProvidersStep."
|
||||||
|
|
||||||
|
### 3. Assess (Antigravity)
|
||||||
|
|
||||||
|
In the Antigravity chat: `/fa-page-review ProvidersStep`
|
||||||
|
|
||||||
|
It opens the page in Storybook, checks it at three screen sizes, and gives you a
|
||||||
|
list of issues — spacing inconsistencies, typography problems, copy that could be
|
||||||
|
warmer, responsive breakpoint issues.
|
||||||
|
|
||||||
|
### 4. Fix (Claude Code)
|
||||||
|
|
||||||
|
You bring the findings to me: "Fix the spacing between the filter chips and the
|
||||||
|
provider list, and tighten up the heading hierarchy."
|
||||||
|
|
||||||
|
I make the targeted changes in the code.
|
||||||
|
|
||||||
|
### 5. Verify (Antigravity)
|
||||||
|
|
||||||
|
Back in Antigravity: `/fa-visual-qa ProvidersStep`
|
||||||
|
|
||||||
|
It screenshots the page at all three breakpoints again so you can compare
|
||||||
|
before and after.
|
||||||
|
|
||||||
|
### 6. Commit (Claude Code)
|
||||||
|
|
||||||
|
Once you're happy: "commit and push." I handle git.
|
||||||
|
|
||||||
|
That's the **sandwich**: Antigravity sees the problem → Claude Code fixes it →
|
||||||
|
Antigravity confirms the fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The flow for building new components
|
||||||
|
|
||||||
|
When you need something new built (a new atom, molecule, organism, or page):
|
||||||
|
|
||||||
|
1. **Tell Claude Code what you need.** I'll check the component registry, verify
|
||||||
|
dependencies, and build it following the lifecycle.
|
||||||
|
2. **I run internal QA automatically** (`/audit`, `/critique`, `/harden`) before
|
||||||
|
showing it to you.
|
||||||
|
3. **Visual sign-off in Antigravity.** Use `/fa-visual-qa ComponentName` to check
|
||||||
|
it at all breakpoints.
|
||||||
|
4. **You give feedback.** I fix, Antigravity re-verifies. One or two rounds.
|
||||||
|
5. **Preflight and commit.** Either tool can run preflight. I handle the commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What about the workflows?
|
||||||
|
|
||||||
|
You have six workflows available in Antigravity's chat. Type `/` followed by the
|
||||||
|
name:
|
||||||
|
|
||||||
|
| Workflow | What it does | When to use it |
|
||||||
|
|----------|-------------|----------------|
|
||||||
|
| `/fa-session-start` | Reads memory files, reports project status | Start of a session |
|
||||||
|
| `/fa-page-review` | Full review of an existing page | When tweaking/polishing pages |
|
||||||
|
| `/fa-visual-qa` | Visual check at 3 breakpoints | After any visual change |
|
||||||
|
| `/fa-preflight` | Runs all quality checks (auto-approves) | Before committing |
|
||||||
|
| `/fa-token-sync` | Rebuilds token outputs (auto-approves) | After changing token JSON |
|
||||||
|
| `/fa-build-component` | Scaffolds a new component | When building something new |
|
||||||
|
|
||||||
|
The ones marked "auto-approves" run without asking — they're safe, read-only or
|
||||||
|
deterministic operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What not to worry about
|
||||||
|
|
||||||
|
**You don't need to keep the tools in sync.** They both read `AGENTS.md` for
|
||||||
|
shared rules. Claude Code reads `CLAUDE.md` for its extra instructions.
|
||||||
|
Antigravity reads `GEMINI.md` for its extra instructions. There's no overlap
|
||||||
|
between those two files, so nothing drifts.
|
||||||
|
|
||||||
|
**You don't need to tell both tools the same thing.** If you make a design
|
||||||
|
decision, tell Claude Code — I'll log it in `decisions-log.md`, and Antigravity
|
||||||
|
can read it from there next time.
|
||||||
|
|
||||||
|
**You don't need to worry about file conflicts.** Claude Code writes code and
|
||||||
|
memory files. Antigravity only reads those files and writes to its own workflow
|
||||||
|
directory. They don't touch each other's files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When the backend comes
|
||||||
|
|
||||||
|
When you start the Payload CMS work, the same structure holds:
|
||||||
|
|
||||||
|
- The **backend agent** (whether it runs in Antigravity or a separate Claude Code
|
||||||
|
session) will own `src/payload/` and `src/api/`. It reads the flow-definition
|
||||||
|
YAML files to know what API endpoints and data shapes to build.
|
||||||
|
- **Claude Code** continues owning frontend components, tokens, and theme.
|
||||||
|
- **Antigravity** continues owning visual QA.
|
||||||
|
- The **flow-definition.yaml** is the contract between frontend and backend — it
|
||||||
|
defines the step IDs, field names, branching logic, and visibility rules that
|
||||||
|
both sides reference.
|
||||||
|
|
||||||
|
We'll add a backend section to `AGENTS.md` when that starts. For now, the
|
||||||
|
boundaries are already defined so the transition will be smooth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick reference card
|
||||||
|
|
||||||
|
| I want to... | Use |
|
||||||
|
|--------------|-----|
|
||||||
|
| Build a component | Claude Code |
|
||||||
|
| Fix spacing/copy/alignment | Claude Code |
|
||||||
|
| Check how something looks | Antigravity (`/fa-visual-qa`) |
|
||||||
|
| Review a whole page | Antigravity (`/fa-page-review`) |
|
||||||
|
| Run quality checks | Either (`/preflight` or `/fa-preflight`) |
|
||||||
|
| Rebuild tokens | Either (`/sync-tokens` or `/fa-token-sync`) |
|
||||||
|
| Commit and push | Claude Code |
|
||||||
|
| Log a design decision | Claude Code |
|
||||||
|
| Check project status | Either (`/status` or `/fa-session-start`) |
|
||||||
|
| Extract from Figma | Claude Code (has Figma MCP) |
|
||||||
@@ -34,10 +34,10 @@ scores on record. Focus on those that scored < 16/20 or were never audited.
|
|||||||
| Atom | Last Audit Score | Priority |
|
| Atom | Last Audit Score | Priority |
|
||||||
|------|-----------------|----------|
|
|------|-----------------|----------|
|
||||||
| Button | **20/20** (2026-03-27) | ~~High~~ Done |
|
| Button | **20/20** (2026-03-27) | ~~High~~ Done |
|
||||||
| Typography | — | Medium (display-only) |
|
| Typography | **18/20** (2026-04-06) | ~~Medium~~ Done — no P0/P1 |
|
||||||
| Input | **20/20** (2026-03-27) | ~~High~~ Done |
|
| Input | **20/20** (2026-03-27) | ~~High~~ Done |
|
||||||
| Card | **20/20** (2026-03-27, after P1 fixes) | ~~High~~ Done |
|
| Card | **20/20** (2026-03-27, after P1 fixes) | ~~High~~ Done |
|
||||||
| Badge | — | Medium (fixed in D031) |
|
| Badge | **15/20** (2026-04-06) | ~~Medium~~ Done — P0 (role="status") reviewed, determined false positive (would create unwanted aria-live region on static labels) |
|
||||||
| Chip | — | Low (minimal wrapper) |
|
| Chip | — | Low (minimal wrapper) |
|
||||||
| Switch | — | Low (minimal wrapper) |
|
| Switch | — | Low (minimal wrapper) |
|
||||||
| Radio | — | Low (minimal wrapper) |
|
| Radio | — | Low (minimal wrapper) |
|
||||||
@@ -45,22 +45,22 @@ scores on record. Focus on those that scored < 16/20 or were never audited.
|
|||||||
| Divider | — | Low (minimal wrapper) |
|
| Divider | — | Low (minimal wrapper) |
|
||||||
| Link | — | Low (minimal wrapper) |
|
| Link | — | Low (minimal wrapper) |
|
||||||
|
|
||||||
**Estimated effort:** 1 session for normalize + audit of high/medium priority atoms.
|
**Estimated effort:** ~~1 session~~ Done (high/medium atoms). Low-priority atoms are minimal MUI wrappers — no further review needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Molecules (composition layer)
|
## Phase 2: Molecules (composition layer)
|
||||||
|
|
||||||
### Step 2.1 — Normalize all molecules
|
### Step 2.1 — Normalize all molecules
|
||||||
Run `/normalize molecules` for cross-component consistency.
|
~~Run `/normalize molecules` for cross-component consistency.~~ Done (2026-04-06). All 9 molecules scanned. No real P0/P1 cross-component issues. displayName ✓, forwardRef ✓, ARIA ✓ (AddOnOption uses Switch as semantic control; ProviderCard has `...rest` passthrough), no hardcoded colours ✓.
|
||||||
|
|
||||||
### Step 2.2 — Audit + critique priority molecules
|
### Step 2.2 — Audit + critique priority molecules
|
||||||
Run `/audit` and `/critique` on molecules with real layout complexity.
|
Run `/audit` and `/critique` on molecules with real layout complexity.
|
||||||
|
|
||||||
| Molecule | Last Scores | Priority |
|
| Molecule | Last Scores | Priority |
|
||||||
|----------|------------|----------|
|
|----------|------------|----------|
|
||||||
| ProviderCard | Critique 33/40 (v2 iteration) | Medium (user-approved) |
|
| ProviderCard | Critique 33/40 (v2 iteration) | ~~Medium~~ Done (user-approved, ARIA passthrough confirmed) |
|
||||||
| VenueCard | Critique 33/40 | Medium (user-approved) |
|
| VenueCard | Critique 33/40 | ~~Medium~~ Done (user-approved) |
|
||||||
| SearchBar | Critique 35/40 | Low (high scores already) |
|
| SearchBar | Critique 35/40 | Low (high scores already) |
|
||||||
| ServiceOption | **Audit 13/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
|
| ServiceOption | **Audit 13/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
|
||||||
| AddOnOption | **Audit 14/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
|
| AddOnOption | **Audit 14/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
|
||||||
@@ -68,34 +68,29 @@ Run `/audit` and `/critique` on molecules with real layout complexity.
|
|||||||
| LineItem | Audit 19/20 | Low (excellent score) |
|
| LineItem | Audit 19/20 | Low (excellent score) |
|
||||||
| ProviderCardCompact | **Audit 15/20** (2026-03-30, P2 fixed) | ~~Medium~~ Done |
|
| ProviderCardCompact | **Audit 15/20** (2026-03-30, P2 fixed) | ~~Medium~~ Done |
|
||||||
|
|
||||||
**Estimated effort:** 1 session for normalize + audit of medium priority molecules.
|
**Estimated effort:** ~~1 session~~ Done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3: Organisms (page-level compositions)
|
## Phase 3: Organisms (page-level compositions)
|
||||||
|
|
||||||
### Step 3.1 — Normalize all organisms
|
### Step 3.1 — Normalize all organisms
|
||||||
Run `/normalize organisms` for cross-component consistency.
|
~~Run `/normalize organisms` for cross-component consistency.~~ Done (2026-04-06). 5 active organisms scanned. displayName ✓, forwardRef ✓. Spacing and token access vary by component but are D031-compliant (CSS vars acceptable for semantic tokens per D031). Focus-visible styles are present in MUI theme overrides for Link and interactive controls.
|
||||||
|
|
||||||
### Step 3.2 — Full review of critical organisms
|
### Step 3.2 — Full review of critical organisms
|
||||||
Organisms are the most complex and user-facing. Run `/audit` + `/critique` +
|
Run `/audit` + `/critique` + `/harden` on each.
|
||||||
`/harden` on each.
|
|
||||||
|
|
||||||
| Organism | Last Scores | Priority |
|
| Organism | Last Scores | Priority |
|
||||||
|----------|------------|----------|
|
|----------|------------|----------|
|
||||||
| Navigation | — | High (site-wide, visible on every page) |
|
| Navigation | **Audit 15/20** (2026-04-06) | ~~High~~ Done — no real P0/P1 after analysis (focus-visible from MUI theme, CSS vars D031-compliant) |
|
||||||
| Footer | Critique 38/40 | Low (excellent score) |
|
| Footer | Critique 38/40 | Low (excellent score) |
|
||||||
| ServiceSelector | — | High (arrangement flow core) |
|
| ServiceSelector | **Audit 17/20** (2026-04-06, P0 fixed: aria-required) | ~~High~~ Done |
|
||||||
| PackageDetail | Audit 19/20 | Low (excellent score) |
|
| PackageDetail | Audit 19/20 | Low (excellent score) |
|
||||||
| FuneralFinder V1 | Audit 14/20, Critique 29/40 | Medium (pending production decision) |
|
| FuneralFinder V1 | Audit 14/20, Critique 29/40 | Archived (D032) |
|
||||||
| FuneralFinder V2 | Audit 18/20, Critique 33/40 | Medium (pending production decision) |
|
| FuneralFinder V2 | Audit 18/20, Critique 33/40 | Archived (D032) |
|
||||||
| FuneralFinder V3 | Audit 18/20, Critique 33/40 | Medium (pending production decision) |
|
| FuneralFinder V3 | Audit 18/20, Critique 33/40 | ~~Medium~~ Done (production version, D032) |
|
||||||
|
|
||||||
**Note on FuneralFinder:** All three versions exist. A production decision (v1 vs v2 vs v3)
|
**Estimated effort:** ~~1 session~~ Done.
|
||||||
is still pending. Only review the chosen version in depth. The others can be archived or
|
|
||||||
retained as alternatives.
|
|
||||||
|
|
||||||
**Estimated effort:** 1 session for normalize + audit/critique of Navigation + ServiceSelector.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,11 +99,11 @@ retained as alternatives.
|
|||||||
After individual components are clean:
|
After individual components are clean:
|
||||||
1. ~~**Form error colour normalisation (D034):**~~ Done (2026-03-28). Copper error styling in MuiOutlinedInput, MuiFormHelperText, MuiFormLabel, ToggleButtonGroup.
|
1. ~~**Form error colour normalisation (D034):**~~ Done (2026-03-28). Copper error styling in MuiOutlinedInput, MuiFormHelperText, MuiFormLabel, ToggleButtonGroup.
|
||||||
2. ~~Run `/adapt` on all organisms + ProviderCard/VenueCard (responsive check)~~ Done (2026-03-31d). Navigation + Footer touch targets fixed (P0/P1). ProviderCard/VenueCard: no action needed (card is the touch target).
|
2. ~~Run `/adapt` on all organisms + ProviderCard/VenueCard (responsive check)~~ Done (2026-03-31d). Navigation + Footer touch targets fixed (P0/P1). ProviderCard/VenueCard: no action needed (card is the touch target).
|
||||||
3. Run `/typeset` across a representative sample of each tier
|
3. Run `/typeset` across a representative sample of each tier — deferred (low risk, typography tokens are consistent)
|
||||||
4. Run `/preflight` to verify the full codebase
|
4. ~~Run `/preflight` to verify the full codebase~~ Done (2026-04-06). TypeScript ✓, ESLint ✓, Prettier ✓, Storybook build ✓.
|
||||||
5. Commit all fixes
|
5. ~~Commit all fixes~~ Done.
|
||||||
|
|
||||||
**Estimated effort:** 0.5 session (remaining: typeset + preflight only).
|
**Estimated effort:** ~~0.5 session~~ Done (typeset deferred as low-priority).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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';
|
||||||
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,373 @@
|
|||||||
|
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',
|
||||||
|
info: 'Allowance amount — upgrade options available.',
|
||||||
|
value: { type: 'allowance', amount: 1800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cremation Certificate/Permit',
|
||||||
|
info: 'Statutory medical referee fee.',
|
||||||
|
value: { type: 'price', amount: 350 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Death Registration Certificate',
|
||||||
|
info: 'Lodgement with NSW Registry.',
|
||||||
|
value: { type: 'price', amount: 70 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Professional Service Fee',
|
||||||
|
info: 'Coordination of arrangements.',
|
||||||
|
value: { type: 'price', amount: 3980 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Transportation Service Fee',
|
||||||
|
info: 'Transfer of the deceased.',
|
||||||
|
value: { type: 'price', amount: 500 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Optionals',
|
||||||
|
items: [
|
||||||
|
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
|
||||||
|
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
|
||||||
|
{
|
||||||
|
name: 'Digital Recording of the Funeral Service',
|
||||||
|
info: 'Professional video recording.',
|
||||||
|
value: { type: 'price', amount: 250 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Extras',
|
||||||
|
items: [
|
||||||
|
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||||
|
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
516
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
516
src/components/organisms/ComparisonTable/ComparisonTable.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
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 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 { Link } from '../../atoms/Link';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Badge color="default" variant="soft" size="small">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
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 cards */}
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<Box
|
||||||
|
key={pkg.id}
|
||||||
|
role="columnheader"
|
||||||
|
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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 && (
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
underline="hover"
|
||||||
|
onClick={() => onRemove(pkg.id)}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Section tables (each separate with left accent headings) ── */}
|
||||||
|
{mergedSections.map((section) => (
|
||||||
|
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
|
||||||
|
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
|
||||||
|
<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={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
{item.info && (
|
||||||
|
<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',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</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';
|
||||||
@@ -14,98 +14,102 @@ const DEMO_IMAGE =
|
|||||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||||
|
|
||||||
const essentials = [
|
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',
|
name: 'Allowance for Coffin',
|
||||||
price: 1500,
|
price: 1750,
|
||||||
isAllowance: true,
|
isAllowance: true,
|
||||||
info: 'Allowance amount — upgrade options available during arrangement.',
|
info: 'Allowance amount — upgrade options available during arrangement.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Allowance for Crematorium',
|
name: 'Cremation Certificate/Permit',
|
||||||
price: 1500,
|
price: 350,
|
||||||
isAllowance: true,
|
info: 'Statutory medical referee fee required for all cremations in NSW.',
|
||||||
info: 'Allowance for crematorium fees — varies by location.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Allowance for Hearse',
|
name: 'Crematorium: Mackay Family Crematorium',
|
||||||
price: 1500,
|
price: 660,
|
||||||
isAllowance: true,
|
info: 'Cremation facility fees at the selected crematorium.',
|
||||||
info: 'Allowance for hearse transfer — distance surcharges may apply.',
|
},
|
||||||
|
{
|
||||||
|
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',
|
name: 'Digital Recording of the Funeral Service',
|
||||||
info: 'Dressing and preparation of the deceased — included at no charge.',
|
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 = {
|
const extras = {
|
||||||
heading: 'Extras',
|
heading: 'Extras',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'Allowance for Flowers',
|
name: 'Allowance for Celebrant',
|
||||||
price: 1500,
|
price: 550,
|
||||||
isAllowance: true,
|
|
||||||
info: 'Seasonal floral arrangements for the service.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Allowance for Master of Ceremonies',
|
|
||||||
price: 1500,
|
|
||||||
isAllowance: true,
|
isAllowance: true,
|
||||||
info: 'Professional celebrant or MC for the funeral service.',
|
info: 'Professional celebrant or MC for the funeral service.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'After Business Hours Service Surcharge',
|
name: 'Catering',
|
||||||
price: 1500,
|
priceLabel: 'Price On Application',
|
||||||
info: 'Additional fee for services held outside standard business hours.',
|
info: 'Catering for the wake or post-service gathering.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'After Hours Prayers',
|
name: 'Newspaper Notice',
|
||||||
price: 1500,
|
priceLabel: 'Price On Application',
|
||||||
info: 'Evening prayer service at the funeral home.',
|
info: 'Published death notice in local or national newspaper.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Coffin Bearing by Funeral Directors',
|
name: 'Saturday Service Fee',
|
||||||
price: 1500,
|
price: 880,
|
||||||
info: 'Professional pallbearing by funeral directors.',
|
info: 'Additional fee for services held on a Saturday.',
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Digital Recording',
|
|
||||||
price: 1500,
|
|
||||||
info: 'Professional video recording of the funeral service.',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -169,16 +173,16 @@ type Story = StoryObj<typeof PackageDetail>;
|
|||||||
|
|
||||||
// --- Default -----------------------------------------------------------------
|
// --- Default -----------------------------------------------------------------
|
||||||
|
|
||||||
/** Full package detail panel — Essentials, Complimentary, Total, then Extras */
|
/** Full package detail panel — Essentials, Optionals, Total, then Extras */
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Everyday Funeral Package',
|
name: 'Traditional Family Cremation Service',
|
||||||
price: 900,
|
price: 6966,
|
||||||
sections: [
|
sections: [
|
||||||
{ heading: 'Essentials', items: essentials },
|
{ heading: 'Essentials', items: essentials },
|
||||||
{ heading: 'Complimentary Items', items: complimentary },
|
{ heading: 'Optionals', items: optionals },
|
||||||
],
|
],
|
||||||
total: 2700,
|
total: 6966,
|
||||||
extras,
|
extras,
|
||||||
terms: termsText,
|
terms: termsText,
|
||||||
onArrange: () => alert('Make Arrangement clicked'),
|
onArrange: () => alert('Make Arrangement clicked'),
|
||||||
@@ -191,10 +195,10 @@ export const Default: Story = {
|
|||||||
/** Compare button in loading state — adding to comparison cart */
|
/** Compare button in loading state — adding to comparison cart */
|
||||||
export const CompareLoading: Story = {
|
export const CompareLoading: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Everyday Funeral Package',
|
name: 'Traditional Family Cremation Service',
|
||||||
price: 900,
|
price: 6966,
|
||||||
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
|
||||||
total: 6000,
|
total: 6966,
|
||||||
onArrange: () => alert('Make Arrangement'),
|
onArrange: () => alert('Make Arrangement'),
|
||||||
onCompare: () => {},
|
onCompare: () => {},
|
||||||
compareLoading: true,
|
compareLoading: true,
|
||||||
@@ -203,16 +207,16 @@ export const CompareLoading: Story = {
|
|||||||
|
|
||||||
// --- Without Extras ----------------------------------------------------------
|
// --- Without Extras ----------------------------------------------------------
|
||||||
|
|
||||||
/** Simpler package with essentials and complimentary only */
|
/** Simpler package with essentials and optionals only — no extras */
|
||||||
export const WithoutExtras: Story = {
|
export const WithoutExtras: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Essential Funeral Package',
|
name: 'Essential Cremation Package',
|
||||||
price: 600,
|
price: 4850,
|
||||||
sections: [
|
sections: [
|
||||||
{ heading: 'Essentials', items: essentials.slice(0, 6) },
|
{ 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,
|
terms: termsText,
|
||||||
onArrange: () => alert('Make Arrangement'),
|
onArrange: () => alert('Make Arrangement'),
|
||||||
onCompare: () => alert('Compare'),
|
onCompare: () => alert('Compare'),
|
||||||
@@ -332,9 +336,9 @@ export const PackageSelectPage: Story = {
|
|||||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||||
sections={[
|
sections={[
|
||||||
{ heading: 'Essentials', items: essentials },
|
{ heading: 'Essentials', items: essentials },
|
||||||
{ heading: 'Complimentary Items', items: complimentary },
|
{ heading: 'Optionals', items: optionals },
|
||||||
]}
|
]}
|
||||||
total={2700}
|
total={6966}
|
||||||
extras={extras}
|
extras={extras}
|
||||||
terms={termsText}
|
terms={termsText}
|
||||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface PackageLineItem {
|
|||||||
price?: number;
|
price?: number;
|
||||||
/** Whether this is an allowance (shows asterisk) */
|
/** Whether this is an allowance (shows asterisk) */
|
||||||
isAllowance?: boolean;
|
isAllowance?: boolean;
|
||||||
|
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
|
||||||
|
priceLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
|
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
|
||||||
@@ -83,6 +85,7 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
|
|||||||
info={item.info}
|
info={item.info}
|
||||||
price={item.price}
|
price={item.price}
|
||||||
isAllowance={item.isAllowance}
|
isAllowance={item.isAllowance}
|
||||||
|
priceLabel={item.priceLabel}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorP
|
|||||||
<Box
|
<Box
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={heading}
|
aria-label={heading}
|
||||||
|
aria-required={continueLabel ? true : undefined}
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
474
src/components/pages/ComparisonPage/ComparisonPage.stories.tsx
Normal file
474
src/components/pages/ComparisonPage/ComparisonPage.stories.tsx
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
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',
|
||||||
|
info: 'Allowance amount.',
|
||||||
|
value: { type: 'allowance', amount: 1800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cremation Certificate/Permit',
|
||||||
|
info: 'Medical referee fee.',
|
||||||
|
value: { type: 'price', amount: 350 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Death Registration Certificate',
|
||||||
|
info: 'NSW Registry.',
|
||||||
|
value: { type: 'price', amount: 70 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Professional Service Fee',
|
||||||
|
info: 'Coordination.',
|
||||||
|
value: { type: 'price', amount: 3980 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Transportation Service Fee',
|
||||||
|
info: 'Transfer.',
|
||||||
|
value: { type: 'price', amount: 500 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Optionals',
|
||||||
|
items: [
|
||||||
|
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
|
||||||
|
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
|
||||||
|
{
|
||||||
|
name: 'Digital Recording',
|
||||||
|
info: 'Video recording.',
|
||||||
|
value: { type: 'price', amount: 250 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Extras',
|
||||||
|
items: [
|
||||||
|
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
|
||||||
|
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
497
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
497
src/components/pages/ComparisonPage/ComparisonPage.tsx
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import React, { useId, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
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 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 { Badge } from '../../atoms/Badge';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
import { Card } from '../../atoms/Card';
|
||||||
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
|
import {
|
||||||
|
ComparisonTable,
|
||||||
|
type ComparisonPackage,
|
||||||
|
type ComparisonCellValue,
|
||||||
|
} from '../../organisms/ComparisonTable';
|
||||||
|
|
||||||
|
// ─── 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatPrice(amount: number): string {
|
||||||
|
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileCellValue({ 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 (
|
||||||
|
<Badge color="default" variant="soft" size="small">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'unavailable':
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mobile card view ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MobilePackageCard({
|
||||||
|
pkg,
|
||||||
|
onArrange,
|
||||||
|
}: {
|
||||||
|
pkg: ComparisonPackage;
|
||||||
|
onArrange: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
selected={pkg.isRecommended}
|
||||||
|
padding="none"
|
||||||
|
sx={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{/* 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="large"
|
||||||
|
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>
|
||||||
|
<MobileCellValue value={item.value} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package comparison page for the FA design system.
|
||||||
|
*
|
||||||
|
* Desktop: Full ComparisonTable with info card, floating verified badges,
|
||||||
|
* section tables with left accent borders.
|
||||||
|
* Mobile: Tabbed card view with horizontal chip rail.
|
||||||
|
*
|
||||||
|
* Share + Print utility actions in the page header.
|
||||||
|
*/
|
||||||
|
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 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' : ''}`;
|
||||||
|
|
||||||
|
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 name */}
|
||||||
|
<Box
|
||||||
|
role="tablist"
|
||||||
|
id={tablistId}
|
||||||
|
aria-label="Packages to compare"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1.5,
|
||||||
|
overflowX: 'auto',
|
||||||
|
pb: 1,
|
||||||
|
mb: 2.5,
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allPackages.map((pkg, idx) => {
|
||||||
|
const isActive = idx === activeTabIdx;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={pkg.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`comparison-tabpanel-${idx}`}
|
||||||
|
id={`comparison-tab-${idx}`}
|
||||||
|
variant="outlined"
|
||||||
|
selected={isActive}
|
||||||
|
padding="none"
|
||||||
|
onClick={() => setActiveTabIdx(idx)}
|
||||||
|
interactive
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: 150,
|
||||||
|
maxWidth: 200,
|
||||||
|
cursor: 'pointer',
|
||||||
|
...(pkg.isRecommended &&
|
||||||
|
!isActive && {
|
||||||
|
borderColor: 'var(--fa-color-brand-500)',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="labelSm"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pkg.isRecommended ? `★ ${pkg.provider.name}` : pkg.provider.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pkg.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activePackage && (
|
||||||
|
<Box
|
||||||
|
role="tabpanel"
|
||||||
|
id={`comparison-tabpanel-${activeTabIdx}`}
|
||||||
|
aria-labelledby={`comparison-tab-${activeTabIdx}`}
|
||||||
|
>
|
||||||
|
<MobilePackageCard pkg={activePackage} onArrange={onArrange} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</WizardLayout>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ComparisonPage.displayName = 'ComparisonPage';
|
||||||
|
export default ComparisonPage;
|
||||||
2
src/components/pages/ComparisonPage/index.ts
Normal file
2
src/components/pages/ComparisonPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ComparisonPage, default } from './ComparisonPage';
|
||||||
|
export type { ComparisonPageProps } from './ComparisonPage';
|
||||||
@@ -26,6 +26,11 @@
|
|||||||
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
|
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
|
||||||
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
|
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
|
||||||
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
|
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
|
||||||
|
--fa-map-pin-height: 28px; /** Pill height — compact for map density */
|
||||||
|
--fa-map-pin-font-size: 12px; /** Small but legible price text */
|
||||||
|
--fa-map-pin-dot-size: 12px; /** Small circle marker */
|
||||||
|
--fa-map-pin-nub-size: 6px; /** Nub triangle size */
|
||||||
|
--fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */
|
||||||
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
|
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
|
||||||
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
|
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
|
||||||
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
|
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
|
||||||
@@ -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-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
|
||||||
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
|
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
|
||||||
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
|
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
|
||||||
|
--fa-map-pin-padding-x: var(--fa-spacing-3); /** 12px horizontal padding inside pill */
|
||||||
|
--fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */
|
||||||
|
--fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */
|
||||||
|
--fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */
|
||||||
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
|
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
|
||||||
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
|
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
|
||||||
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
|
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */
|
||||||
|
|||||||
9
src/theme/generated/tokens.d.ts
vendored
9
src/theme/generated/tokens.d.ts
vendored
@@ -71,6 +71,15 @@ export declare const InputFontSizeDefault: string;
|
|||||||
export declare const InputBorderRadiusDefault: string;
|
export declare const InputBorderRadiusDefault: string;
|
||||||
export declare const InputGapDefault: string;
|
export declare const InputGapDefault: string;
|
||||||
export declare const InputIconSizeDefault: string;
|
export declare const InputIconSizeDefault: string;
|
||||||
|
export declare const MapPinHeight: string;
|
||||||
|
export declare const MapPinPaddingX: string;
|
||||||
|
export declare const MapPinFontSize: string;
|
||||||
|
export declare const MapPinBorderRadius: string;
|
||||||
|
export declare const MapPinDotSize: string;
|
||||||
|
export declare const MapPinNubSize: string;
|
||||||
|
export declare const MiniCardImageHeight: string;
|
||||||
|
export declare const MiniCardContentPadding: string;
|
||||||
|
export declare const MiniCardContentGap: string;
|
||||||
export declare const ProviderCardImageHeight: string;
|
export declare const ProviderCardImageHeight: string;
|
||||||
export declare const ProviderCardLogoSize: string;
|
export declare const ProviderCardLogoSize: string;
|
||||||
export declare const ProviderCardLogoBorderRadius: string;
|
export declare const ProviderCardLogoBorderRadius: string;
|
||||||
|
|||||||
@@ -72,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 InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
|
||||||
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
|
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
|
||||||
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
|
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
|
||||||
|
export const MapPinHeight = "28px"; // Pill height — compact for map density
|
||||||
|
export const MapPinPaddingX = "12px"; // 12px horizontal padding inside pill
|
||||||
|
export const MapPinFontSize = "12px"; // Small but legible price text
|
||||||
|
export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape
|
||||||
|
export const MapPinDotSize = "12px"; // Small circle marker
|
||||||
|
export const MapPinNubSize = "6px"; // Nub triangle size
|
||||||
|
export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids
|
||||||
|
export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding
|
||||||
|
export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows
|
||||||
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
|
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
|
||||||
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
|
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
|
||||||
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius
|
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius
|
||||||
|
|||||||
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